diff options
Diffstat (limited to 'app')
59 files changed, 1201 insertions, 1201 deletions
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 43ad127a4db..1a791395d6f 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -14,7 +14,6 @@ /* global NotificationsForm */ /* global TreeView */ /* global NotificationsDropdown */ -/* global UsersSelect */ /* global GroupAvatar */ /* global LineHighlighter */ /* global ProjectFork */ @@ -52,6 +51,7 @@ import ShortcutsWiki from './shortcuts_wiki'; import Pipelines from './pipelines'; import BlobViewer from './blob/viewer/index'; import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; +import UsersSelect from './users_select'; const ShortcutsBlob = require('./shortcuts_blob'); @@ -113,6 +113,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'projects:boards:show': case 'projects:boards:index': shortcut_handler = new ShortcutsNavigation(); + new UsersSelect(); break; case 'projects:builds:show': new Build(); @@ -127,6 +128,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); prefixId: page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_', }); shortcut_handler = new ShortcutsNavigation(); + new UsersSelect(); break; case 'projects:issues:show': new Issue(); @@ -139,6 +141,10 @@ const ShortcutsBlob = require('./shortcuts_blob'); new Milestone(); new Sidebar(); break; + case 'groups:issues': + case 'groups:merge_requests': + new UsersSelect(); + break; case 'dashboard:todos:index': new gl.Todos(); break; @@ -223,6 +229,10 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'dashboard:activity': new gl.Activities(); break; + case 'dashboard:issues': + case 'dashboard:merge_requests': + new UsersSelect(); + break; case 'projects:commit:show': new Commit(); new gl.Diff(); @@ -377,6 +387,9 @@ const ShortcutsBlob = require('./shortcuts_blob'); new LineHighlighter(); new BlobViewer(); break; + case 'import:fogbugz:new_user_map': + new UsersSelect(); + break; } switch (path.first()) { case 'sessions': diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index 834b98e8601..4520e990e6f 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -1,8 +1,8 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-new, comma-dangle, quotes, prefer-arrow-callback, consistent-return, one-var, no-var, one-var-declaration-per-line, no-underscore-dangle, max-len */ -/* global UsersSelect */ /* global bp */ import Cookies from 'js-cookie'; +import UsersSelect from './users_select'; (function() { this.IssuableContext = (function() { diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index 544fc91876a..4310663e0b6 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -1,11 +1,12 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, no-useless-escape, no-new, quotes, object-shorthand, no-unused-vars, comma-dangle, no-alert, consistent-return, no-else-return, prefer-template, one-var, one-var-declaration-per-line, curly, max-len */ /* global GitLab */ -/* global UsersSelect */ /* global ZenMode */ /* global Autosave */ /* global dateFormat */ /* global Pikaday */ +import UsersSelect from './users_select'; + (function() { this.IssuableForm = (function() { IssuableForm.prototype.issueMoveConfirmMsg = 'Are you sure you want to move this issue to another project?'; diff --git a/app/assets/javascripts/issue_show/actions/tasks.js b/app/assets/javascripts/issue_show/actions/tasks.js deleted file mode 100644 index 0740a9f559c..00000000000 --- a/app/assets/javascripts/issue_show/actions/tasks.js +++ /dev/null @@ -1,27 +0,0 @@ -export default (newStateData, tasks) => { - const $tasks = $('#task_status'); - const $tasksShort = $('#task_status_short'); - const $issueableHeader = $('.issuable-header'); - const tasksStates = { newState: null, currentState: null }; - - if ($tasks.length === 0) { - if (!(newStateData.task_status.indexOf('0 of 0') === 0)) { - $issueableHeader.append(`<span id="task_status">${newStateData.task_status}</span>`); - } else { - $issueableHeader.append('<span id="task_status"></span>'); - } - } else { - tasksStates.newState = newStateData.task_status.indexOf('0 of 0') === 0; - tasksStates.currentState = tasks.indexOf('0 of 0') === 0; - } - - if ($tasks.length !== 0 && !tasksStates.newState) { - $tasks.text(newStateData.task_status); - $tasksShort.text(newStateData.task_status); - } else if (tasksStates.currentState) { - $issueableHeader.append(`<span id="task_status">${newStateData.task_status}</span>`); - } else if (tasksStates.newState) { - $tasks.remove(); - $tasksShort.remove(); - } -}; diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue new file mode 100644 index 00000000000..770a0dcd27e --- /dev/null +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -0,0 +1,96 @@ +<script> +import Visibility from 'visibilityjs'; +import Poll from '../../lib/utils/poll'; +import Service from '../services/index'; +import Store from '../stores'; +import titleComponent from './title.vue'; +import descriptionComponent from './description.vue'; + +export default { + props: { + endpoint: { + required: true, + type: String, + }, + canUpdate: { + required: true, + type: Boolean, + }, + issuableRef: { + type: String, + required: true, + }, + initialTitle: { + type: String, + required: true, + }, + initialDescriptionHtml: { + type: String, + required: false, + default: '', + }, + initialDescriptionText: { + type: String, + required: false, + default: '', + }, + }, + data() { + const store = new Store({ + titleHtml: this.initialTitle, + descriptionHtml: this.initialDescriptionHtml, + descriptionText: this.initialDescriptionText, + }); + + return { + store, + state: store.state, + }; + }, + components: { + descriptionComponent, + titleComponent, + }, + created() { + const resource = new Service(this.endpoint); + const poll = new Poll({ + resource, + method: 'getData', + successCallback: (res) => { + this.store.updateState(res.json()); + }, + errorCallback(err) { + throw new Error(err); + }, + }); + + if (!Visibility.hidden()) { + poll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + poll.restart(); + } else { + poll.stop(); + } + }); + }, +}; +</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" /> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue new file mode 100644 index 00000000000..4ad3eb7dfd7 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -0,0 +1,105 @@ +<script> + import animateMixin from '../mixins/animate'; + + export default { + mixins: [animateMixin], + props: { + canUpdate: { + type: Boolean, + required: true, + }, + descriptionHtml: { + type: String, + required: true, + }, + descriptionText: { + type: String, + required: true, + }, + updatedAt: { + type: String, + required: true, + }, + taskStatus: { + type: String, + required: true, + }, + }, + data() { + return { + preAnimation: false, + pulseAnimation: false, + timeAgoEl: $('.js-issue-edited-ago'), + }; + }, + watch: { + descriptionHtml() { + this.animateChange(); + + this.$nextTick(() => { + const toolTipTime = gl.utils.formatDate(this.updatedAt); + + this.timeAgoEl.attr('datetime', this.updatedAt) + .attr('title', toolTipTime) + .tooltip('fixTitle'); + + this.renderGFM(); + }); + }, + taskStatus() { + const taskRegexMatches = this.taskStatus.match(/(\d+) of (\d+)/); + const $issuableHeader = $('.issuable-meta'); + const $tasks = $('#task_status', $issuableHeader); + const $tasksShort = $('#task_status_short', $issuableHeader); + + if (taskRegexMatches) { + $tasks.text(this.taskStatus); + $tasksShort.text(`${taskRegexMatches[1]}/${taskRegexMatches[2]} task${taskRegexMatches[2] > 1 ? 's' : ''}`); + } else { + $tasks.text(''); + $tasksShort.text(''); + } + }, + }, + methods: { + renderGFM() { + $(this.$refs['gfm-entry-content']).renderGFM(); + + if (this.canUpdate) { + // eslint-disable-next-line no-new + new gl.TaskList({ + dataType: 'issue', + fieldName: 'description', + selector: '.detail-page-description', + }); + } + }, + }, + mounted() { + this.renderGFM(); + }, + }; +</script> + +<template> + <div + class="description" + :class="{ + 'js-task-list-container': canUpdate + }"> + <div + class="wiki" + :class="{ + 'issue-realtime-pre-pulse': preAnimation, + 'issue-realtime-trigger-pulse': pulseAnimation + }" + v-html="descriptionHtml" + ref="gfm-content"> + </div> + <textarea + class="hidden js-task-list-field" + v-if="descriptionText" + v-model="descriptionText"> + </textarea> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue new file mode 100644 index 00000000000..a9dabd4cff1 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/title.vue @@ -0,0 +1,53 @@ +<script> + import animateMixin from '../mixins/animate'; + + export default { + mixins: [animateMixin], + data() { + return { + preAnimation: false, + pulseAnimation: false, + titleEl: document.querySelector('title'), + }; + }, + props: { + issuableRef: { + type: String, + required: true, + }, + titleHtml: { + type: String, + required: true, + }, + titleText: { + type: String, + required: true, + }, + }, + watch: { + titleHtml() { + this.setPageTitle(); + this.animateChange(); + }, + }, + methods: { + setPageTitle() { + const currentPageTitleScope = this.titleEl.innerText.split('·'); + currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `; + this.titleEl.textContent = currentPageTitleScope.join('·'); + }, + }, + }; +</script> + +<template> + <h2 + class="title" + :class="{ + 'issue-realtime-pre-pulse': preAnimation, + 'issue-realtime-trigger-pulse': pulseAnimation + }" + v-html="titleHtml" + > + </h2> +</template> diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index eb20a597bb5..f06e33dee60 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -1,20 +1,42 @@ import Vue from 'vue'; -import IssueTitle from './issue_title_description.vue'; +import issuableApp from './components/app.vue'; import '../vue_shared/vue_resource_interceptor'; -(() => { - const issueTitleData = document.querySelector('.issue-title-data').dataset; - const { canUpdateTasksClass, endpoint } = issueTitleData; +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; - const vm = new Vue({ - el: '.issue-title-entrypoint', - render: createElement => createElement(IssueTitle, { + 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: { - canUpdateTasksClass, - endpoint, + canUpdate: this.canUpdate, + endpoint: this.endpoint, + issuableRef: this.issuableRef, + initialTitle: this.initialTitle, + initialDescriptionHtml: this.initialDescriptionHtml, + initialDescriptionText: this.initialDescriptionText, }, - }), - }); - - return vm; -})(); + }); + }, +})); diff --git a/app/assets/javascripts/issue_show/issue_title_description.vue b/app/assets/javascripts/issue_show/issue_title_description.vue deleted file mode 100644 index dc3ba2550c5..00000000000 --- a/app/assets/javascripts/issue_show/issue_title_description.vue +++ /dev/null @@ -1,180 +0,0 @@ -<script> -import Visibility from 'visibilityjs'; -import Poll from './../lib/utils/poll'; -import Service from './services/index'; -import tasks from './actions/tasks'; - -export default { - props: { - endpoint: { - required: true, - type: String, - }, - canUpdateTasksClass: { - required: true, - type: String, - }, - }, - data() { - const resource = new Service(this.$http, this.endpoint); - - const poll = new Poll({ - resource, - method: 'getTitle', - successCallback: (res) => { - this.renderResponse(res); - }, - errorCallback: (err) => { - throw new Error(err); - }, - }); - - return { - poll, - apiData: {}, - tasks: '0 of 0', - title: null, - titleText: '', - titleFlag: { - pre: true, - pulse: false, - }, - description: null, - descriptionText: '', - descriptionChange: false, - descriptionFlag: { - pre: true, - pulse: false, - }, - timeAgoEl: $('.issue_edited_ago'), - titleEl: document.querySelector('title'), - }; - }, - methods: { - updateFlag(key, toggle) { - this[key].pre = toggle; - this[key].pulse = !toggle; - }, - renderResponse(res) { - this.apiData = res.json(); - this.triggerAnimation(); - }, - updateTaskHTML() { - tasks(this.apiData, this.tasks); - }, - elementsToVisualize(noTitleChange, noDescriptionChange) { - if (!noTitleChange) { - this.titleText = this.apiData.title_text; - this.updateFlag('titleFlag', true); - } - - if (!noDescriptionChange) { - // only change to true when we need to bind TaskLists the html of description - this.descriptionChange = true; - this.updateTaskHTML(); - this.tasks = this.apiData.task_status; - this.updateFlag('descriptionFlag', true); - } - }, - setTabTitle() { - const currentTabTitleScope = this.titleEl.innerText.split('·'); - currentTabTitleScope[0] = `${this.titleText} (#${this.apiData.issue_number}) `; - this.titleEl.innerText = currentTabTitleScope.join('·'); - }, - animate(title, description) { - this.title = title; - this.description = description; - this.setTabTitle(); - - this.$nextTick(() => { - this.updateFlag('titleFlag', false); - this.updateFlag('descriptionFlag', false); - }); - }, - triggerAnimation() { - // always reset to false before checking the change - this.descriptionChange = false; - - const { title, description } = this.apiData; - this.descriptionText = this.apiData.description_text; - - const noTitleChange = this.title === title; - const noDescriptionChange = this.description === description; - - /** - * since opacity is changed, even if there is no diff for Vue to update - * we must check the title/description even on a 304 to ensure no visual change - */ - if (noTitleChange && noDescriptionChange) return; - - this.elementsToVisualize(noTitleChange, noDescriptionChange); - this.animate(title, description); - }, - updateEditedTimeAgo() { - const toolTipTime = gl.utils.formatDate(this.apiData.updated_at); - this.timeAgoEl.attr('datetime', this.apiData.updated_at); - this.timeAgoEl.attr('title', toolTipTime).tooltip('fixTitle'); - }, - }, - created() { - if (!Visibility.hidden()) { - this.poll.makeRequest(); - } - - Visibility.change(() => { - if (!Visibility.hidden()) { - this.poll.restart(); - } else { - this.poll.stop(); - } - }); - }, - updated() { - // if new html is injected (description changed) - bind TaskList and call renderGFM - if (this.descriptionChange) { - this.updateEditedTimeAgo(); - - $(this.$refs['issue-content-container-gfm-entry']).renderGFM(); - - const tl = new gl.TaskList({ - dataType: 'issue', - fieldName: 'description', - selector: '.detail-page-description', - }); - - return tl && null; - } - - return null; - }, -}; -</script> - -<template> - <div> - <h2 - class="title" - :class="{ 'issue-realtime-pre-pulse': titleFlag.pre, 'issue-realtime-trigger-pulse': titleFlag.pulse }" - ref="issue-title" - v-html="title" - > - </h2> - <div - class="description is-task-list-enabled" - :class="canUpdateTasksClass" - v-if="description" - > - <div - class="wiki" - :class="{ 'issue-realtime-pre-pulse': descriptionFlag.pre, 'issue-realtime-trigger-pulse': descriptionFlag.pulse }" - v-html="description" - ref="issue-content-container-gfm-entry" - > - </div> - <textarea - class="hidden js-task-list-field" - v-if="descriptionText" - >{{descriptionText}}</textarea> - </div> - </div> -</template> diff --git a/app/assets/javascripts/issue_show/mixins/animate.js b/app/assets/javascripts/issue_show/mixins/animate.js new file mode 100644 index 00000000000..eda6302aa8b --- /dev/null +++ b/app/assets/javascripts/issue_show/mixins/animate.js @@ -0,0 +1,13 @@ +export default { + methods: { + animateChange() { + this.preAnimation = true; + this.pulseAnimation = false; + + this.$nextTick(() => { + this.preAnimation = false; + this.pulseAnimation = true; + }); + }, + }, +}; diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js index c4ab0b1e07a..348ad8d6813 100644 --- a/app/assets/javascripts/issue_show/services/index.js +++ b/app/assets/javascripts/issue_show/services/index.js @@ -1,10 +1,16 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + export default class Service { - constructor(resource, endpoint) { - this.resource = resource; + constructor(endpoint) { this.endpoint = endpoint; + + this.resource = Vue.resource(this.endpoint); } - getTitle() { - return this.resource.get(this.endpoint); + getData() { + return this.resource.get(); } } diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js new file mode 100644 index 00000000000..8e89a2b7730 --- /dev/null +++ b/app/assets/javascripts/issue_show/stores/index.js @@ -0,0 +1,25 @@ +export default class Store { + constructor({ + titleHtml, + descriptionHtml, + descriptionText, + }) { + this.state = { + titleHtml, + titleText: '', + descriptionHtml, + descriptionText, + taskStatus: '', + updatedAt: '', + }; + } + + updateState(data) { + this.state.titleHtml = data.title; + this.state.titleText = data.title_text; + this.state.descriptionHtml = data.description; + this.state.descriptionText = data.description_text; + this.state.taskStatus = data.task_status; + this.state.updatedAt = data.updated_at; + } +} diff --git a/app/assets/javascripts/pipelines/components/stage.js b/app/assets/javascripts/pipelines/components/stage.js deleted file mode 100644 index 034e8d3280e..00000000000 --- a/app/assets/javascripts/pipelines/components/stage.js +++ /dev/null @@ -1,104 +0,0 @@ -/* global Flash */ -import { borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons'; - -export default { - data() { - return { - builds: '', - spinner: '<span class="fa fa-spinner fa-spin"></span>', - }; - }, - - props: { - stage: { - type: Object, - required: true, - }, - }, - - updated() { - if (this.builds) { - this.stopDropdownClickPropagation(); - } - }, - - methods: { - fetchBuilds(e) { - const ariaExpanded = e.currentTarget.attributes['aria-expanded']; - - if (ariaExpanded && (ariaExpanded.textContent === 'true')) return null; - - return this.$http.get(this.stage.dropdown_path) - .then((response) => { - this.builds = JSON.parse(response.body).html; - }, () => { - const flash = new Flash('Something went wrong on our end.'); - return flash; - }); - }, - - /** - * When the user right clicks or cmd/ctrl + click in the job name - * the dropdown should not be closed and the link should open in another tab, - * so we stop propagation of the click event inside the dropdown. - * - * Since this component is rendered multiple times per page we need to guarantee we only - * target the click event of this component. - */ - stopDropdownClickPropagation() { - $(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item')).on('click', (e) => { - e.stopPropagation(); - }); - }, - }, - computed: { - buildsOrSpinner() { - return this.builds ? this.builds : this.spinner; - }, - dropdownClass() { - if (this.builds) return 'js-builds-dropdown-container'; - return 'js-builds-dropdown-loading builds-dropdown-loading'; - }, - buildStatus() { - return `Build: ${this.stage.status.label}`; - }, - tooltip() { - return `has-tooltip ci-status-icon ci-status-icon-${this.stage.status.group}`; - }, - triggerButtonClass() { - return `mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button ci-status-icon-${this.stage.status.group}`; - }, - svgHTML() { - return borderlessStatusIconEntityMap[this.stage.status.icon]; - }, - }, - watch: { - 'stage.title': function stageTitle() { - $(this.$refs.button).tooltip('destroy').tooltip(); - }, - }, - template: ` - <div> - <button - @click="fetchBuilds($event)" - :class="triggerButtonClass" - :title="stage.title" - data-placement="top" - data-toggle="dropdown" - type="button" - ref="button" - :aria-label="stage.title"> - <span v-html="svgHTML" aria-hidden="true"></span> - <i class="fa fa-caret-down" aria-hidden="true"></i> - </button> - <ul class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container"> - <div class="arrow-up" aria-hidden="true"></div> - <div - :class="dropdownClass" - class="js-builds-dropdown-list scrollable-menu" - v-html="buildsOrSpinner"> - </div> - </ul> - </div> - `, -}; diff --git a/app/assets/javascripts/pipelines/components/status.js b/app/assets/javascripts/pipelines/components/status.js deleted file mode 100644 index 21a281af438..00000000000 --- a/app/assets/javascripts/pipelines/components/status.js +++ /dev/null @@ -1,60 +0,0 @@ -import canceledSvg from 'icons/_icon_status_canceled.svg'; -import createdSvg from 'icons/_icon_status_created.svg'; -import failedSvg from 'icons/_icon_status_failed.svg'; -import manualSvg from 'icons/_icon_status_manual.svg'; -import pendingSvg from 'icons/_icon_status_pending.svg'; -import runningSvg from 'icons/_icon_status_running.svg'; -import skippedSvg from 'icons/_icon_status_skipped.svg'; -import successSvg from 'icons/_icon_status_success.svg'; -import warningSvg from 'icons/_icon_status_warning.svg'; - -export default { - props: { - pipeline: { - type: Object, - required: true, - }, - }, - - data() { - const svgsDictionary = { - icon_status_canceled: canceledSvg, - icon_status_created: createdSvg, - icon_status_failed: failedSvg, - icon_status_manual: manualSvg, - icon_status_pending: pendingSvg, - icon_status_running: runningSvg, - icon_status_skipped: skippedSvg, - icon_status_success: successSvg, - icon_status_warning: warningSvg, - }; - - return { - svg: svgsDictionary[this.pipeline.details.status.icon], - }; - }, - - computed: { - cssClasses() { - return `ci-status ci-${this.pipeline.details.status.group}`; - }, - - detailsPath() { - const { status } = this.pipeline.details; - return status.has_details ? status.details_path : false; - }, - - content() { - return `${this.svg} ${this.pipeline.details.status.text}`; - }, - }, - template: ` - <td class="commit-link"> - <a - :class="cssClasses" - :href="detailsPath" - v-html="content"> - </a> - </td> - `, -}; diff --git a/app/assets/javascripts/todos.js b/app/assets/javascripts/todos.js index 8be58023c84..7230946b484 100644 --- a/app/assets/javascripts/todos.js +++ b/app/assets/javascripts/todos.js @@ -1,5 +1,6 @@ /* eslint-disable class-methods-use-this, no-unneeded-ternary, quote-props */ -/* global UsersSelect */ + +import UsersSelect from './users_select'; class Todos { constructor() { diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 452578d5b98..8119a8cd000 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -5,655 +5,649 @@ // TODO: remove eventHub hack after code splitting refactor window.emitSidebarEvent = window.emitSidebarEvent || $.noop; -(function() { - const slice = [].slice; - - this.UsersSelect = (function() { - function UsersSelect(currentUser, els) { - var $els; - this.users = this.users.bind(this); - this.user = this.user.bind(this); - this.usersPath = "/autocomplete/users.json"; - this.userPath = "/autocomplete/users/:id.json"; - if (currentUser != null) { - if (typeof currentUser === 'object') { - this.currentUser = currentUser; - } else { - this.currentUser = JSON.parse(currentUser); +function UsersSelect(currentUser, els) { + var $els; + this.users = this.users.bind(this); + this.user = this.user.bind(this); + this.usersPath = "/autocomplete/users.json"; + this.userPath = "/autocomplete/users/:id.json"; + if (currentUser != null) { + if (typeof currentUser === 'object') { + this.currentUser = currentUser; + } else { + this.currentUser = JSON.parse(currentUser); + } + } + + $els = $(els); + + if (!els) { + $els = $('.js-user-search'); + } + + $els.each((function(_this) { + return function(i, dropdown) { + var options = {}; + var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, defaultNullUser, firstUser, issueURL, selectedId, selectedIdDefault, showAnyUser, showNullUser, showMenuAbove; + $dropdown = $(dropdown); + options.projectId = $dropdown.data('project-id'); + options.groupId = $dropdown.data('group-id'); + options.showCurrentUser = $dropdown.data('current-user'); + options.todoFilter = $dropdown.data('todo-filter'); + options.todoStateFilter = $dropdown.data('todo-state-filter'); + showNullUser = $dropdown.data('null-user'); + defaultNullUser = $dropdown.data('null-user-default'); + showMenuAbove = $dropdown.data('showMenuAbove'); + showAnyUser = $dropdown.data('any-user'); + firstUser = $dropdown.data('first-user'); + options.authorId = $dropdown.data('author-id'); + defaultLabel = $dropdown.data('default-label'); + issueURL = $dropdown.data('issueUpdate'); + $selectbox = $dropdown.closest('.selectbox'); + $block = $selectbox.closest('.block'); + abilityName = $dropdown.data('ability-name'); + $value = $block.find('.value'); + $collapsedSidebar = $block.find('.sidebar-collapsed-user'); + $loading = $block.find('.block-loading').fadeOut(); + selectedIdDefault = (defaultNullUser && showNullUser) ? 0 : null; + selectedId = $dropdown.data('selected') || selectedIdDefault; + + const assignYourself = function () { + const unassignedSelected = $dropdown.closest('.selectbox') + .find(`input[name='${$dropdown.data('field-name')}'][value=0]`); + + if (unassignedSelected) { + unassignedSelected.remove(); } - } - $els = $(els); + // Save current selected user to the DOM + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = $dropdown.data('field-name'); + + const currentUserInfo = $dropdown.data('currentUserInfo'); + + if (currentUserInfo) { + input.value = currentUserInfo.id; + input.dataset.meta = currentUserInfo.name; + } else if (_this.currentUser) { + input.value = _this.currentUser.id; + } - if (!els) { - $els = $('.js-user-search'); + if ($selectbox) { + $dropdown.parent().before(input); + } else { + $dropdown.after(input); + } + }; + + if ($block[0]) { + $block[0].addEventListener('assignYourself', assignYourself); } - $els.each((function(_this) { - return function(i, dropdown) { - var options = {}; - var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, defaultNullUser, firstUser, issueURL, selectedId, selectedIdDefault, showAnyUser, showNullUser, showMenuAbove; - $dropdown = $(dropdown); - options.projectId = $dropdown.data('project-id'); - options.groupId = $dropdown.data('group-id'); - options.showCurrentUser = $dropdown.data('current-user'); - options.todoFilter = $dropdown.data('todo-filter'); - options.todoStateFilter = $dropdown.data('todo-state-filter'); - showNullUser = $dropdown.data('null-user'); - defaultNullUser = $dropdown.data('null-user-default'); - showMenuAbove = $dropdown.data('showMenuAbove'); - showAnyUser = $dropdown.data('any-user'); - firstUser = $dropdown.data('first-user'); - options.authorId = $dropdown.data('author-id'); - defaultLabel = $dropdown.data('default-label'); - issueURL = $dropdown.data('issueUpdate'); - $selectbox = $dropdown.closest('.selectbox'); - $block = $selectbox.closest('.block'); - abilityName = $dropdown.data('ability-name'); - $value = $block.find('.value'); - $collapsedSidebar = $block.find('.sidebar-collapsed-user'); - $loading = $block.find('.block-loading').fadeOut(); - selectedIdDefault = (defaultNullUser && showNullUser) ? 0 : null; - selectedId = $dropdown.data('selected') || selectedIdDefault; - - const assignYourself = function () { - const unassignedSelected = $dropdown.closest('.selectbox') - .find(`input[name='${$dropdown.data('field-name')}'][value=0]`); - - if (unassignedSelected) { - unassignedSelected.remove(); - } + const getSelectedUserInputs = function() { + return $selectbox + .find(`input[name="${$dropdown.data('field-name')}"]`); + }; + + const getSelected = function() { + return getSelectedUserInputs() + .map((index, input) => parseInt(input.value, 10)) + .get(); + }; + + const checkMaxSelect = function() { + const maxSelect = $dropdown.data('max-select'); + if (maxSelect) { + const selected = getSelected(); + + if (selected.length > maxSelect) { + const firstSelectedId = selected[0]; + const firstSelected = $dropdown.closest('.selectbox') + .find(`input[name='${$dropdown.data('field-name')}'][value=${firstSelectedId}]`); + + firstSelected.remove(); + emitSidebarEvent('sidebar.removeAssignee', { + id: firstSelectedId, + }); + } + } + }; + + const getMultiSelectDropdownTitle = function(selectedUser, isSelected) { + const selectedUsers = getSelected() + .filter(u => u !== 0); + + const firstUser = getSelectedUserInputs() + .map((index, input) => ({ + name: input.dataset.meta, + value: parseInt(input.value, 10), + })) + .filter(u => u.id !== 0) + .get(0); + + if (selectedUsers.length === 0) { + return 'Unassigned'; + } else if (selectedUsers.length === 1) { + return firstUser.name; + } else if (isSelected) { + const otherSelected = selectedUsers.filter(s => s !== selectedUser.id); + return `${selectedUser.name} + ${otherSelected.length} more`; + } else { + return `${firstUser.name} + ${selectedUsers.length - 1} more`; + } + }; - // Save current selected user to the DOM - const input = document.createElement('input'); - input.type = 'hidden'; - input.name = $dropdown.data('field-name'); + $('.assign-to-me-link').on('click', (e) => { + e.preventDefault(); + $(e.currentTarget).hide(); - const currentUserInfo = $dropdown.data('currentUserInfo'); + if ($dropdown.data('multiSelect')) { + assignYourself(); + checkMaxSelect(); - if (currentUserInfo) { - input.value = currentUserInfo.id; - input.dataset.meta = currentUserInfo.name; - } else if (_this.currentUser) { - input.value = _this.currentUser.id; - } + const currentUserInfo = $dropdown.data('currentUserInfo'); + $dropdown.find('.dropdown-toggle-text').text(getMultiSelectDropdownTitle(currentUserInfo)).removeClass('is-default'); + } else { + const $input = $(`input[name="${$dropdown.data('field-name')}"]`); + $input.val(gon.current_user_id); + selectedId = $input.val(); + $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default'); + } + }); - if ($selectbox) { - $dropdown.parent().before(input); - } else { - $dropdown.after(input); - } - }; + $block.on('click', '.js-assign-yourself', (e) => { + e.preventDefault(); + return assignTo(_this.currentUser.id); + }); - if ($block[0]) { - $block[0].addEventListener('assignYourself', assignYourself); + assignTo = function(selected) { + var data; + data = {}; + data[abilityName] = {}; + data[abilityName].assignee_id = selected != null ? selected : null; + $loading.removeClass('hidden').fadeIn(); + $dropdown.trigger('loading.gl.dropdown'); + + return $.ajax({ + type: 'PUT', + dataType: 'json', + url: issueURL, + data: data + }).done(function(data) { + var user; + $dropdown.trigger('loaded.gl.dropdown'); + $loading.fadeOut(); + if (data.assignee) { + user = { + name: data.assignee.name, + username: data.assignee.username, + avatar: data.assignee.avatar_url + }; + } else { + user = { + name: 'Unassigned', + username: '', + avatar: '' + }; } - - const getSelectedUserInputs = function() { - return $selectbox - .find(`input[name="${$dropdown.data('field-name')}"]`); - }; - - const getSelected = function() { - return getSelectedUserInputs() - .map((index, input) => parseInt(input.value, 10)) - .get(); - }; - - const checkMaxSelect = function() { - const maxSelect = $dropdown.data('max-select'); - if (maxSelect) { - const selected = getSelected(); - - if (selected.length > maxSelect) { - const firstSelectedId = selected[0]; - const firstSelected = $dropdown.closest('.selectbox') - .find(`input[name='${$dropdown.data('field-name')}'][value=${firstSelectedId}]`); - - firstSelected.remove(); - emitSidebarEvent('sidebar.removeAssignee', { - id: firstSelectedId, - }); + $value.html(assigneeTemplate(user)); + $collapsedSidebar.attr('title', user.name).tooltip('fixTitle'); + return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); + }); + }; + collapsedAssigneeTemplate = _.template('<% if( avatar ) { %> <a class="author_link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>'); + assigneeTemplate = _.template('<% if (username) { %> <a class="author_link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> No assignee - <a href="#" class="js-assign-yourself"> assign yourself </a> </span> <% } %>'); + return $dropdown.glDropdown({ + showMenuAbove: showMenuAbove, + data: function(term, callback) { + var isAuthorFilter; + isAuthorFilter = $('.js-author-search'); + return _this.users(term, options, function(users) { + // GitLabDropdownFilter returns this.instance + // GitLabDropdownRemote returns this.options.instance + const glDropdown = this.instance || this.options.instance; + glDropdown.options.processData(term, users, callback); + }.bind(this)); + }, + processData: function(term, users, callback) { + let anyUser; + let index; + let j; + let len; + let name; + let obj; + let showDivider; + if (term.length === 0) { + showDivider = 0; + if (firstUser) { + // Move current user to the front of the list + for (index = j = 0, len = users.length; j < len; index = (j += 1)) { + obj = users[index]; + if (obj.username === firstUser) { + users.splice(index, 1); + users.unshift(obj); + break; + } } } - }; - - const getMultiSelectDropdownTitle = function(selectedUser, isSelected) { - const selectedUsers = getSelected() - .filter(u => u !== 0); - - const firstUser = getSelectedUserInputs() - .map((index, input) => ({ - name: input.dataset.meta, - value: parseInt(input.value, 10), - })) - .filter(u => u.id !== 0) - .get(0); - - if (selectedUsers.length === 0) { - return 'Unassigned'; - } else if (selectedUsers.length === 1) { - return firstUser.name; - } else if (isSelected) { - const otherSelected = selectedUsers.filter(s => s !== selectedUser.id); - return `${selectedUser.name} + ${otherSelected.length} more`; - } else { - return `${firstUser.name} + ${selectedUsers.length - 1} more`; + if (showNullUser) { + showDivider += 1; + users.unshift({ + beforeDivider: true, + name: 'Unassigned', + id: 0 + }); + } + if (showAnyUser) { + showDivider += 1; + name = showAnyUser; + if (name === true) { + name = 'Any User'; + } + anyUser = { + beforeDivider: true, + name: name, + id: null + }; + users.unshift(anyUser); } - }; - - $('.assign-to-me-link').on('click', (e) => { - e.preventDefault(); - $(e.currentTarget).hide(); - - if ($dropdown.data('multiSelect')) { - assignYourself(); - checkMaxSelect(); - const currentUserInfo = $dropdown.data('currentUserInfo'); - $dropdown.find('.dropdown-toggle-text').text(getMultiSelectDropdownTitle(currentUserInfo)).removeClass('is-default'); - } else { - const $input = $(`input[name="${$dropdown.data('field-name')}"]`); - $input.val(gon.current_user_id); - selectedId = $input.val(); - $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default'); + if (showDivider) { + users.splice(showDivider, 0, 'divider'); } - }); - $block.on('click', '.js-assign-yourself', (e) => { - e.preventDefault(); - return assignTo(_this.currentUser.id); - }); + if ($dropdown.hasClass('js-multiselect')) { + const selected = getSelected().filter(i => i !== 0); - assignTo = function(selected) { - var data; - data = {}; - data[abilityName] = {}; - data[abilityName].assignee_id = selected != null ? selected : null; - $loading.removeClass('hidden').fadeIn(); - $dropdown.trigger('loading.gl.dropdown'); - - return $.ajax({ - type: 'PUT', - dataType: 'json', - url: issueURL, - data: data - }).done(function(data) { - var user; - $dropdown.trigger('loaded.gl.dropdown'); - $loading.fadeOut(); - if (data.assignee) { - user = { - name: data.assignee.name, - username: data.assignee.username, - avatar: data.assignee.avatar_url - }; - } else { - user = { - name: 'Unassigned', - username: '', - avatar: '' - }; - } - $value.html(assigneeTemplate(user)); - $collapsedSidebar.attr('title', user.name).tooltip('fixTitle'); - return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); - }); - }; - collapsedAssigneeTemplate = _.template('<% if( avatar ) { %> <a class="author_link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>'); - assigneeTemplate = _.template('<% if (username) { %> <a class="author_link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> No assignee - <a href="#" class="js-assign-yourself"> assign yourself </a> </span> <% } %>'); - return $dropdown.glDropdown({ - showMenuAbove: showMenuAbove, - data: function(term, callback) { - var isAuthorFilter; - isAuthorFilter = $('.js-author-search'); - return _this.users(term, options, function(users) { - // GitLabDropdownFilter returns this.instance - // GitLabDropdownRemote returns this.options.instance - const glDropdown = this.instance || this.options.instance; - glDropdown.options.processData(term, users, callback); - }.bind(this)); - }, - processData: function(term, users, callback) { - let anyUser; - let index; - let j; - let len; - let name; - let obj; - let showDivider; - if (term.length === 0) { - showDivider = 0; - if (firstUser) { - // Move current user to the front of the list - for (index = j = 0, len = users.length; j < len; index = (j += 1)) { - obj = users[index]; - if (obj.username === firstUser) { - users.splice(index, 1); - users.unshift(obj); - break; - } - } - } - if (showNullUser) { + if (selected.length > 0) { + if ($dropdown.data('dropdown-header')) { showDivider += 1; - users.unshift({ - beforeDivider: true, - name: 'Unassigned', - id: 0 + users.splice(showDivider, 0, { + header: $dropdown.data('dropdown-header'), }); } - if (showAnyUser) { - showDivider += 1; - name = showAnyUser; - if (name === true) { - name = 'Any User'; - } - anyUser = { - beforeDivider: true, - name: name, - id: null - }; - users.unshift(anyUser); - } - if (showDivider) { - users.splice(showDivider, 0, 'divider'); - } + const selectedUsers = users + .filter(u => selected.indexOf(u.id) !== -1) + .sort((a, b) => a.name > b.name); - if ($dropdown.hasClass('js-multiselect')) { - const selected = getSelected().filter(i => i !== 0); + users = users.filter(u => selected.indexOf(u.id) === -1); - if (selected.length > 0) { - if ($dropdown.data('dropdown-header')) { - showDivider += 1; - users.splice(showDivider, 0, { - header: $dropdown.data('dropdown-header'), - }); - } - - const selectedUsers = users - .filter(u => selected.indexOf(u.id) !== -1) - .sort((a, b) => a.name > b.name); + selectedUsers.forEach((selectedUser) => { + showDivider += 1; + users.splice(showDivider, 0, selectedUser); + }); - users = users.filter(u => selected.indexOf(u.id) === -1); + users.splice(showDivider + 1, 0, 'divider'); + } + } + } - selectedUsers.forEach((selectedUser) => { - showDivider += 1; - users.splice(showDivider, 0, selectedUser); - }); + callback(users); + if (showMenuAbove) { + $dropdown.data('glDropdown').positionMenuAbove(); + } + }, + filterable: true, + filterRemote: true, + search: { + fields: ['name', 'username'] + }, + selectable: true, + fieldName: $dropdown.data('field-name'), + toggleLabel: function(selected, el, glDropdown) { + const inputValue = glDropdown.filterInput.val(); + + if (this.multiSelect && inputValue === '') { + // Remove non-users from the fullData array + const users = glDropdown.filteredFullData(); + const callback = glDropdown.parseData.bind(glDropdown); + + // Update the data model + this.processData(inputValue, users, callback); + } - users.splice(showDivider + 1, 0, 'divider'); - } - } - } + if (this.multiSelect) { + return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active')); + } - callback(users); - if (showMenuAbove) { - $dropdown.data('glDropdown').positionMenuAbove(); - } - }, - filterable: true, - filterRemote: true, - search: { - fields: ['name', 'username'] - }, - selectable: true, - fieldName: $dropdown.data('field-name'), - toggleLabel: function(selected, el, glDropdown) { - const inputValue = glDropdown.filterInput.val(); - - if (this.multiSelect && inputValue === '') { - // Remove non-users from the fullData array - const users = glDropdown.filteredFullData(); - const callback = glDropdown.parseData.bind(glDropdown); - - // Update the data model - this.processData(inputValue, users, callback); - } + if (selected && 'id' in selected && $(el).hasClass('is-active')) { + $dropdown.find('.dropdown-toggle-text').removeClass('is-default'); + if (selected.text) { + return selected.text; + } else { + return selected.name; + } + } else { + $dropdown.find('.dropdown-toggle-text').addClass('is-default'); + return defaultLabel; + } + }, + defaultLabel: defaultLabel, + hidden: function(e) { + if ($dropdown.hasClass('js-multiselect')) { + emitSidebarEvent('sidebar.saveAssignees'); + } - if (this.multiSelect) { - return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active')); - } + if (!$dropdown.data('always-show-selectbox')) { + $selectbox.hide(); - if (selected && 'id' in selected && $(el).hasClass('is-active')) { - $dropdown.find('.dropdown-toggle-text').removeClass('is-default'); - if (selected.text) { - return selected.text; - } else { - return selected.name; - } - } else { - $dropdown.find('.dropdown-toggle-text').addClass('is-default'); - return defaultLabel; - } - }, - defaultLabel: defaultLabel, - hidden: function(e) { - if ($dropdown.hasClass('js-multiselect')) { - emitSidebarEvent('sidebar.saveAssignees'); - } + // Recalculate where .value is because vue might have changed it + $block = $selectbox.closest('.block'); + $value = $block.find('.value'); + // display:block overrides the hide-collapse rule + $value.css('display', ''); + } + }, + multiSelect: $dropdown.hasClass('js-multiselect'), + inputMeta: $dropdown.data('input-meta'), + clicked: function(options) { + const { $el, e, isMarking } = options; + const user = options.selectedObj; + + if ($dropdown.hasClass('js-multiselect')) { + const isActive = $el.hasClass('is-active'); + const previouslySelected = $dropdown.closest('.selectbox') + .find("input[name='" + ($dropdown.data('field-name')) + "'][value!=0]"); + + // Enables support for limiting the number of users selected + // Automatically removes the first on the list if more users are selected + checkMaxSelect(); + + if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') { + // Unassigned selected + previouslySelected.each((index, element) => { + const id = parseInt(element.value, 10); + element.remove(); + }); + emitSidebarEvent('sidebar.removeAllAssignees'); + } else if (isActive) { + // user selected + emitSidebarEvent('sidebar.addAssignee', user); - if (!$dropdown.data('always-show-selectbox')) { - $selectbox.hide(); + // Remove unassigned selection (if it was previously selected) + const unassignedSelected = $dropdown.closest('.selectbox') + .find("input[name='" + ($dropdown.data('field-name')) + "'][value=0]"); - // Recalculate where .value is because vue might have changed it - $block = $selectbox.closest('.block'); - $value = $block.find('.value'); - // display:block overrides the hide-collapse rule - $value.css('display', ''); + if (unassignedSelected) { + unassignedSelected.remove(); + } + } else { + if (previouslySelected.length === 0) { + // Select unassigned because there is no more selected users + this.addInput($dropdown.data('field-name'), 0, {}); } - }, - multiSelect: $dropdown.hasClass('js-multiselect'), - inputMeta: $dropdown.data('input-meta'), - clicked: function(options) { - const { $el, e, isMarking } = options; - const user = options.selectedObj; - - if ($dropdown.hasClass('js-multiselect')) { - const isActive = $el.hasClass('is-active'); - const previouslySelected = $dropdown.closest('.selectbox') - .find("input[name='" + ($dropdown.data('field-name')) + "'][value!=0]"); - - // Enables support for limiting the number of users selected - // Automatically removes the first on the list if more users are selected - checkMaxSelect(); - - if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') { - // Unassigned selected - previouslySelected.each((index, element) => { - const id = parseInt(element.value, 10); - element.remove(); - }); - emitSidebarEvent('sidebar.removeAllAssignees'); - } else if (isActive) { - // user selected - emitSidebarEvent('sidebar.addAssignee', user); - // Remove unassigned selection (if it was previously selected) - const unassignedSelected = $dropdown.closest('.selectbox') - .find("input[name='" + ($dropdown.data('field-name')) + "'][value=0]"); + // User unselected + emitSidebarEvent('sidebar.removeAssignee', user); + } - if (unassignedSelected) { - unassignedSelected.remove(); - } - } else { - if (previouslySelected.length === 0) { - // Select unassigned because there is no more selected users - this.addInput($dropdown.data('field-name'), 0, {}); - } + if (getSelected().find(u => u === gon.current_user_id)) { + $('.assign-to-me-link').hide(); + } else { + $('.assign-to-me-link').show(); + } + } - // User unselected - emitSidebarEvent('sidebar.removeAssignee', user); - } + var isIssueIndex, isMRIndex, page, selected; + page = $('body').data('page'); + isIssueIndex = page === 'projects:issues:index'; + isMRIndex = (page === page && page === 'projects:merge_requests:index'); + if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { + e.preventDefault(); - if (getSelected().find(u => u === gon.current_user_id)) { - $('.assign-to-me-link').hide(); - } else { - $('.assign-to-me-link').show(); - } - } + const isSelecting = (user.id !== selectedId); + selectedId = isSelecting ? user.id : selectedIdDefault; - var isIssueIndex, isMRIndex, page, selected; - page = $('body').data('page'); - isIssueIndex = page === 'projects:issues:index'; - isMRIndex = (page === page && page === 'projects:merge_requests:index'); - if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { - e.preventDefault(); + if (selectedId === gon.current_user_id) { + $('.assign-to-me-link').hide(); + } else { + $('.assign-to-me-link').show(); + } + return; + } + if ($el.closest('.add-issues-modal').length) { + gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id; + } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + return Issuable.filterResults($dropdown.closest('form')); + } else if ($dropdown.hasClass('js-filter-submit')) { + return $dropdown.closest('form').submit(); + } else if (!$dropdown.hasClass('js-multiselect')) { + selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val(); + return assignTo(selected); + } + }, + id: function (user) { + return user.id; + }, + opened: function(e) { + const $el = $(e.currentTarget); + if ($dropdown.hasClass('js-issue-board-sidebar')) { + selectedId = parseInt($dropdown[0].dataset.selected, 10) || selectedIdDefault; + } + $el.find('.is-active').removeClass('is-active'); - const isSelecting = (user.id !== selectedId); - selectedId = isSelecting ? user.id : selectedIdDefault; + function highlightSelected(id) { + $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active'); + } - if (selectedId === gon.current_user_id) { - $('.assign-to-me-link').hide(); - } else { - $('.assign-to-me-link').show(); - } - return; - } - if ($el.closest('.add-issues-modal').length) { - gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id; - } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { - return Issuable.filterResults($dropdown.closest('form')); - } else if ($dropdown.hasClass('js-filter-submit')) { - return $dropdown.closest('form').submit(); - } else if (!$dropdown.hasClass('js-multiselect')) { - selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val(); - return assignTo(selected); - } - }, - id: function (user) { - return user.id; - }, - opened: function(e) { - const $el = $(e.currentTarget); - if ($dropdown.hasClass('js-issue-board-sidebar')) { - selectedId = parseInt($dropdown[0].dataset.selected, 10) || selectedIdDefault; - } - $el.find('.is-active').removeClass('is-active'); + if ($selectbox[0]) { + getSelected().forEach(selectedId => highlightSelected(selectedId)); + } else { + highlightSelected(selectedId); + } + }, + updateLabel: $dropdown.data('dropdown-title'), + renderRow: function(user) { + var avatar, img, listClosingTags, listWithName, listWithUserName, username; + username = user.username ? "@" + user.username : ""; + avatar = user.avatar_url ? user.avatar_url : false; - function highlightSelected(id) { - $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active'); - } + let selected = user.id === parseInt(selectedId, 10); - if ($selectbox[0]) { - getSelected().forEach(selectedId => highlightSelected(selectedId)); - } else { - highlightSelected(selectedId); - } - }, - updateLabel: $dropdown.data('dropdown-title'), - renderRow: function(user) { - var avatar, img, listClosingTags, listWithName, listWithUserName, username; - username = user.username ? "@" + user.username : ""; - avatar = user.avatar_url ? user.avatar_url : false; + if (this.multiSelect) { + const fieldName = this.fieldName; + const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']"); - let selected = user.id === parseInt(selectedId, 10); + if (field.length) { + selected = true; + } + } - if (this.multiSelect) { - const fieldName = this.fieldName; - const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']"); + img = ""; + if (user.beforeDivider != null) { + `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`; + } else { + if (avatar) { + img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />"; + } + } - if (field.length) { - selected = true; + return ` + <li data-user-id=${user.id}> + <a href='#' class='dropdown-menu-user-link ${selected === true ? 'is-active' : ''}'> + ${img} + <strong class='dropdown-menu-user-full-name'> + ${user.name} + </strong> + ${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''} + </a> + </li> + `; + } + }); + }; + })(this)); + $('.ajax-users-select').each((function(_this) { + return function(i, select) { + var firstUser, showAnyUser, showEmailUser, showNullUser; + var options = {}; + options.skipLdap = $(select).hasClass('skip_ldap'); + options.projectId = $(select).data('project-id'); + options.groupId = $(select).data('group-id'); + options.showCurrentUser = $(select).data('current-user'); + options.pushCodeToProtectedBranches = $(select).data('push-code-to-protected-branches'); + options.authorId = $(select).data('author-id'); + options.skipUsers = $(select).data('skip-users'); + showNullUser = $(select).data('null-user'); + showAnyUser = $(select).data('any-user'); + showEmailUser = $(select).data('email-user'); + firstUser = $(select).data('first-user'); + return $(select).select2({ + placeholder: "Search for a user", + multiple: $(select).hasClass('multiselect'), + minimumInputLength: 0, + query: function(query) { + return _this.users(query.term, options, function(users) { + var anyUser, data, emailUser, index, j, len, name, nullUser, obj, ref; + data = { + results: users + }; + if (query.term.length === 0) { + if (firstUser) { + // Move current user to the front of the list + ref = data.results; + for (index = j = 0, len = ref.length; j < len; index = (j += 1)) { + obj = ref[index]; + if (obj.username === firstUser) { + data.results.splice(index, 1); + data.results.unshift(obj); + break; + } } } - - img = ""; - if (user.beforeDivider != null) { - `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`; - } else { - if (avatar) { - img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />"; + if (showNullUser) { + nullUser = { + name: 'Unassigned', + id: 0 + }; + data.results.unshift(nullUser); + } + if (showAnyUser) { + name = showAnyUser; + if (name === true) { + name = 'Any User'; } + anyUser = { + name: name, + id: null + }; + data.results.unshift(anyUser); } - - return ` - <li data-user-id=${user.id}> - <a href='#' class='dropdown-menu-user-link ${selected === true ? 'is-active' : ''}'> - ${img} - <strong class='dropdown-menu-user-full-name'> - ${user.name} - </strong> - ${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''} - </a> - </li> - `; } - }); - }; - })(this)); - $('.ajax-users-select').each((function(_this) { - return function(i, select) { - var firstUser, showAnyUser, showEmailUser, showNullUser; - var options = {}; - options.skipLdap = $(select).hasClass('skip_ldap'); - options.projectId = $(select).data('project-id'); - options.groupId = $(select).data('group-id'); - options.showCurrentUser = $(select).data('current-user'); - options.pushCodeToProtectedBranches = $(select).data('push-code-to-protected-branches'); - options.authorId = $(select).data('author-id'); - options.skipUsers = $(select).data('skip-users'); - showNullUser = $(select).data('null-user'); - showAnyUser = $(select).data('any-user'); - showEmailUser = $(select).data('email-user'); - firstUser = $(select).data('first-user'); - return $(select).select2({ - placeholder: "Search for a user", - multiple: $(select).hasClass('multiselect'), - minimumInputLength: 0, - query: function(query) { - return _this.users(query.term, options, function(users) { - var anyUser, data, emailUser, index, j, len, name, nullUser, obj, ref; - data = { - results: users - }; - if (query.term.length === 0) { - if (firstUser) { - // Move current user to the front of the list - ref = data.results; - for (index = j = 0, len = ref.length; j < len; index = (j += 1)) { - obj = ref[index]; - if (obj.username === firstUser) { - data.results.splice(index, 1); - data.results.unshift(obj); - break; - } - } - } - if (showNullUser) { - nullUser = { - name: 'Unassigned', - id: 0 - }; - data.results.unshift(nullUser); - } - if (showAnyUser) { - name = showAnyUser; - if (name === true) { - name = 'Any User'; - } - anyUser = { - name: name, - id: null - }; - data.results.unshift(anyUser); - } - } - if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) { - var trimmed = query.term.trim(); - emailUser = { - name: "Invite \"" + query.term + "\"", - username: trimmed, - id: trimmed - }; - data.results.unshift(emailUser); - } - return query.callback(data); - }); - }, - initSelection: function() { - var args; - args = 1 <= arguments.length ? slice.call(arguments, 0) : []; - return _this.initSelection.apply(_this, args); - }, - formatResult: function() { - var args; - args = 1 <= arguments.length ? slice.call(arguments, 0) : []; - return _this.formatResult.apply(_this, args); - }, - formatSelection: function() { - var args; - args = 1 <= arguments.length ? slice.call(arguments, 0) : []; - return _this.formatSelection.apply(_this, args); - }, - dropdownCssClass: "ajax-users-dropdown", - // we do not want to escape markup since we are displaying html in results - escapeMarkup: function(m) { - return m; + if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) { + var trimmed = query.term.trim(); + emailUser = { + name: "Invite \"" + query.term + "\"", + username: trimmed, + id: trimmed + }; + data.results.unshift(emailUser); } + return query.callback(data); }); - }; - })(this)); - } - - UsersSelect.prototype.initSelection = function(element, callback) { - var id, nullUser; - id = $(element).val(); - if (id === "0") { - nullUser = { - name: 'Unassigned' - }; - return callback(nullUser); - } else if (id !== "") { - return this.user(id, callback); - } - }; - - UsersSelect.prototype.formatResult = function(user) { - var avatar; - if (user.avatar_url) { - avatar = user.avatar_url; - } else { - avatar = gon.default_avatar_url; - } - return "<div class='user-result " + (!user.username ? 'no-username' : void 0) + "'> <div class='user-image'><img class='avatar s24' src='" + avatar + "'></div> <div class='user-name'>" + user.name + "</div> <div class='user-username'>" + (user.username || "") + "</div> </div>"; - }; - - UsersSelect.prototype.formatSelection = function(user) { - return user.name; - }; - - UsersSelect.prototype.user = function(user_id, callback) { - if (!/^\d+$/.test(user_id)) { - return false; - } - - var url; - url = this.buildUrl(this.userPath); - url = url.replace(':id', user_id); - return $.ajax({ - url: url, - dataType: "json" - }).done(function(user) { - return callback(user); - }); - }; - - // Return users list. Filtered by query - // Only active users retrieved - UsersSelect.prototype.users = function(query, options, callback) { - var url; - url = this.buildUrl(this.usersPath); - return $.ajax({ - url: url, - data: { - search: query, - per_page: 20, - active: true, - project_id: options.projectId || null, - group_id: options.groupId || null, - skip_ldap: options.skipLdap || null, - todo_filter: options.todoFilter || null, - todo_state_filter: options.todoStateFilter || null, - current_user: options.showCurrentUser || null, - push_code_to_protected_branches: options.pushCodeToProtectedBranches || null, - author_id: options.authorId || null, - skip_users: options.skipUsers || null }, - dataType: "json" - }).done(function(users) { - return callback(users); + initSelection: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.initSelection.apply(_this, args); + }, + formatResult: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.formatResult.apply(_this, args); + }, + formatSelection: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.formatSelection.apply(_this, args); + }, + dropdownCssClass: "ajax-users-dropdown", + // we do not want to escape markup since we are displaying html in results + escapeMarkup: function(m) { + return m; + } }); }; - - UsersSelect.prototype.buildUrl = function(url) { - if (gon.relative_url_root != null) { - url = gon.relative_url_root.replace(/\/$/, '') + url; - } - return url; + })(this)); +} + +UsersSelect.prototype.initSelection = function(element, callback) { + var id, nullUser; + id = $(element).val(); + if (id === "0") { + nullUser = { + name: 'Unassigned' }; - - return UsersSelect; - })(); -}).call(window); + return callback(nullUser); + } else if (id !== "") { + return this.user(id, callback); + } +}; + +UsersSelect.prototype.formatResult = function(user) { + var avatar; + if (user.avatar_url) { + avatar = user.avatar_url; + } else { + avatar = gon.default_avatar_url; + } + return "<div class='user-result " + (!user.username ? 'no-username' : void 0) + "'> <div class='user-image'><img class='avatar s24' src='" + avatar + "'></div> <div class='user-name'>" + user.name + "</div> <div class='user-username'>" + (user.username || "") + "</div> </div>"; +}; + +UsersSelect.prototype.formatSelection = function(user) { + return user.name; +}; + +UsersSelect.prototype.user = function(user_id, callback) { + if (!/^\d+$/.test(user_id)) { + return false; + } + + var url; + url = this.buildUrl(this.userPath); + url = url.replace(':id', user_id); + return $.ajax({ + url: url, + dataType: "json" + }).done(function(user) { + return callback(user); + }); +}; + +// Return users list. Filtered by query +// Only active users retrieved +UsersSelect.prototype.users = function(query, options, callback) { + var url; + url = this.buildUrl(this.usersPath); + return $.ajax({ + url: url, + data: { + search: query, + per_page: 20, + active: true, + project_id: options.projectId || null, + group_id: options.groupId || null, + skip_ldap: options.skipLdap || null, + todo_filter: options.todoFilter || null, + todo_state_filter: options.todoStateFilter || null, + current_user: options.showCurrentUser || null, + push_code_to_protected_branches: options.pushCodeToProtectedBranches || null, + author_id: options.authorId || null, + skip_users: options.skipUsers || null + }, + dataType: "json" + }).done(function(users) { + return callback(users); + }); +}; + +UsersSelect.prototype.buildUrl = function(url) { + if (gon.relative_url_root != null) { + url = gon.relative_url_root.replace(/\/$/, '') + url; + } + return url; +}; + +export default UsersSelect; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js index 3c23b8e472b..8b59e018836 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js @@ -1,7 +1,7 @@ /* global Flash */ import '~/lib/utils/datetime_utility'; -import { statusClassToSvgMap } from '../../vue_shared/pipeline_svg_icons'; +import { statusIconEntityMap } from '../../vue_shared/ci_status_icons'; import MemoryUsage from './mr_widget_memory_usage'; import MRWidgetService from '../services/mr_widget_service'; @@ -16,7 +16,7 @@ export default { }, computed: { svg() { - return statusClassToSvgMap.icon_status_success; + return statusIconEntityMap.icon_status_success; }, }, methods: { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js index 801b9fb1ba1..d8c27013cd3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js @@ -1,6 +1,6 @@ -import PipelineStage from '../../pipelines/components/stage'; -import pipelineStatusIcon from '../../vue_shared/components/pipeline_status_icon'; -import { statusClassToSvgMap } from '../../vue_shared/pipeline_svg_icons'; +import PipelineStage from '../../pipelines/components/stage.vue'; +import ciIcon from '../../vue_shared/components/ci_icon.vue'; +import { statusIconEntityMap } from '../../vue_shared/ci_status_icons'; export default { name: 'MRWidgetPipeline', @@ -9,7 +9,7 @@ export default { }, components: { 'pipeline-stage': PipelineStage, - 'pipeline-status-icon': pipelineStatusIcon, + ciIcon, }, computed: { hasCIError() { @@ -18,11 +18,14 @@ export default { return hasCI && !ciStatus; }, svg() { - return statusClassToSvgMap.icon_status_failed; + return statusIconEntityMap.icon_status_failed; }, stageText() { return this.mr.pipeline.details.stages.length > 1 ? 'stages' : 'stage'; }, + status() { + return this.mr.pipeline.details.status || {}; + }, }, template: ` <div class="mr-widget-heading"> @@ -38,7 +41,13 @@ export default { <span>Could not connect to the CI server. Please check your settings and try again.</span> </template> <template v-else> - <pipeline-status-icon :pipelineStatus="mr.pipelineDetailedStatus" /> + <div> + <a + class="icon-link" + :href="this.status.details_path"> + <ci-icon :status="status" /> + </a> + </div> <span> Pipeline <a diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js new file mode 100644 index 00000000000..79f8ef408e6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js @@ -0,0 +1,16 @@ +export default { + name: 'MRWidgetSHAMismatch', + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + The source branch HEAD has recently changed. Please reload the page and review the changes before merging. + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js index b2eb32ead5f..bfe30ee4c08 100644 --- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -27,6 +27,7 @@ export { default as NothingToMergeState } from './components/states/mr_widget_no export { default as MissingBranchState } from './components/states/mr_widget_missing_branch'; export { default as NotAllowedState } from './components/states/mr_widget_not_allowed'; export { default as ReadyToMergeState } from './components/states/mr_widget_ready_to_merge'; +export { default as SHAMismatchState } from './components/states/mr_widget_sha_mismatch'; export { default as UnresolvedDiscussionsState } from './components/states/mr_widget_unresolved_discussions'; export { default as PipelineBlockedState } from './components/states/mr_widget_pipeline_blocked'; export { default as PipelineFailedState } from './components/states/mr_widget_pipeline_failed'; 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 7c6c2d21714..5452e19bd8e 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 @@ -16,6 +16,7 @@ import { MissingBranchState, NotAllowedState, ReadyToMergeState, + SHAMismatchState, UnresolvedDiscussionsState, PipelineBlockedState, PipelineFailedState, @@ -203,6 +204,7 @@ export default { 'mr-widget-not-allowed': NotAllowedState, 'mr-widget-missing-branch': MissingBranchState, 'mr-widget-ready-to-merge': ReadyToMergeState, + 'mr-widget-sha-mismatch': SHAMismatchState, 'mr-widget-squash-before-merge': SquashBeforeMerge, 'mr-widget-checking': CheckingState, 'mr-widget-unresolved-discussions': UnresolvedDiscussionsState, diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index fee4113f3c8..fb78ea92da1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -21,6 +21,8 @@ export default function deviseState(data) { return 'unresolvedDiscussions'; } else if (this.isPipelineBlocked) { return 'pipelineBlocked'; + } else if (this.hasSHAChanged) { + return 'shaMismatch'; } else if (this.canBeMerged) { return 'readyToMerge'; } 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 faafeae5c5b..05e67706983 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 @@ -4,6 +4,7 @@ import { getStateKey } from '../dependencies'; export default class MergeRequestStore { constructor(data) { + this.startingSha = data.diff_head_sha; this.setData(data); } @@ -67,6 +68,7 @@ export default class MergeRequestStore { this.canMerge = !!data.merge_path; this.canCreateIssue = currentUser.can_create_issue || false; this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path; + this.hasSHAChanged = this.sha !== this.startingSha; this.canBeMerged = data.can_be_merged || false; // Cherry-pick and Revert actions related diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js index 625d7a01c65..605dd3a1ff4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js @@ -16,6 +16,7 @@ const stateToComponentMap = { mergeWhenPipelineSucceeds: 'mr-widget-merge-when-pipeline-succeeds', failedToMerge: 'mr-widget-failed-to-merge', autoMergeFailed: 'mr-widget-auto-merge-failed', + shaMismatch: 'mr-widget-sha-mismatch', }; const statesToShowHelpWidget = [ diff --git a/app/assets/javascripts/vue_shared/ci_action_icons.js b/app/assets/javascripts/vue_shared/ci_action_icons.js index ee41dc95beb..b21f0ab49fd 100644 --- a/app/assets/javascripts/vue_shared/ci_action_icons.js +++ b/app/assets/javascripts/vue_shared/ci_action_icons.js @@ -3,24 +3,19 @@ import retrySVG from 'icons/_icon_action_retry.svg'; import playSVG from 'icons/_icon_action_play.svg'; import stopSVG from 'icons/_icon_action_stop.svg'; +/** + * For the provided action returns the respective SVG + * + * @param {String} action + * @return {SVG|String} + */ export default function getActionIcon(action) { - let icon; - switch (action) { - case 'icon_action_cancel': - icon = cancelSVG; - break; - case 'icon_action_retry': - icon = retrySVG; - break; - case 'icon_action_play': - icon = playSVG; - break; - case 'icon_action_stop': - icon = stopSVG; - break; - default: - icon = ''; - } + const icons = { + icon_action_cancel: cancelSVG, + icon_action_play: playSVG, + icon_action_retry: retrySVG, + icon_action_stop: stopSVG, + }; - return icon; + return icons[action] || ''; } diff --git a/app/assets/javascripts/vue_shared/ci_status_icons.js b/app/assets/javascripts/vue_shared/ci_status_icons.js index 48ad9214ac8..d9d0cad38e4 100644 --- a/app/assets/javascripts/vue_shared/ci_status_icons.js +++ b/app/assets/javascripts/vue_shared/ci_status_icons.js @@ -41,15 +41,3 @@ export const statusIconEntityMap = { icon_status_success: SUCCESS_SVG, icon_status_warning: WARNING_SVG, }; - -export const statusCssClasses = { - icon_status_canceled: 'canceled', - icon_status_created: 'created', - icon_status_failed: 'failed', - icon_status_manual: 'manual', - icon_status_pending: 'pending', - icon_status_running: 'running', - icon_status_skipped: 'skipped', - icon_status_success: 'success', - icon_status_warning: 'warning', -}; diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue new file mode 100644 index 00000000000..caa28bff6db --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -0,0 +1,52 @@ +<script> +import ciIcon from './ci_icon.vue'; +/** + * Renders CI Badge link with CI icon and status text based on + * API response shared between all places where it is used. + * + * Receives status object containing: + * status: { + * details_path: "/gitlab-org/gitlab-ce/pipelines/8150156" // url + * group:"running" // used for CSS class + * icon: "icon_status_running" // used to render the icon + * label:"running" // used for potential tooltip + * text:"running" // text rendered + * } + * + * Used in: + * - Pipelines table - first column + * - Jobs table - first column + * - Pipeline show view - header + * - Job show view - header + * - MR widget + */ + +export default { + props: { + status: { + type: Object, + required: true, + }, + }, + + components: { + ciIcon, + }, + + computed: { + cssClass() { + const className = this.status.group; + + return className ? `ci-status ci-${this.status.group}` : 'ci-status'; + }, + }, +}; +</script> +<template> + <a + :href="status.details_path" + :class="cssClass"> + <ci-icon :status="status" /> + {{status.text}} + </a> +</template> diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue index 4d44baaa3c4..ec88119e16c 100644 --- a/app/assets/javascripts/vue_shared/components/ci_icon.vue +++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue @@ -1,6 +1,27 @@ <script> - import { statusIconEntityMap, statusCssClasses } from '../../vue_shared/ci_status_icons'; + import { statusIconEntityMap } from '../ci_status_icons'; + /** + * Renders CI icon based on API response shared between all places where it is used. + * + * Receives status object containing: + * status: { + * details_path: "/gitlab-org/gitlab-ce/pipelines/8150156" // url + * group:"running" // used for CSS class + * icon: "icon_status_running" // used to render the icon + * label:"running" // used for potential tooltip + * text:"running" // text rendered + * } + * + * Used in: + * - Pipelines table Badge + * - Pipelines table mini graph + * - Pipeline graph + * - Pipeline show view badge + * - Jobs table + * - Jobs show view header + * - Jobs show view sidebar + */ export default { props: { status: { @@ -15,7 +36,7 @@ }, cssClass() { - const status = statusCssClasses[this.status.icon]; + const status = this.status.group; return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status}`; }, }, diff --git a/app/assets/javascripts/vue_shared/components/pipeline_status_icon.js b/app/assets/javascripts/vue_shared/components/pipeline_status_icon.js deleted file mode 100644 index ae246ada01b..00000000000 --- a/app/assets/javascripts/vue_shared/components/pipeline_status_icon.js +++ /dev/null @@ -1,23 +0,0 @@ -import { statusClassToSvgMap } from '../pipeline_svg_icons'; - -export default { - name: 'PipelineStatusIcon', - props: { - pipelineStatus: { type: Object, required: true, default: () => ({}) }, - }, - computed: { - svg() { - return statusClassToSvgMap[this.pipelineStatus.icon]; - }, - statusClass() { - return `ci-status-icon ci-status-icon-${this.pipelineStatus.group}`; - }, - }, - template: ` - <div :class="statusClass"> - <a class="icon-link" :href="pipelineStatus.details_path"> - <span v-html="svg" aria-hidden="true"></span> - </a> - </div> - `, -}; 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 7ac7ceaa4e5..30d16e4ed3e 100644 --- a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js +++ b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js @@ -2,7 +2,7 @@ import AsyncButtonComponent from '../../pipelines/components/async_button.vue'; import PipelinesActionsComponent from '../../pipelines/components/pipelines_actions'; import PipelinesArtifactsComponent from '../../pipelines/components/pipelines_artifacts'; -import PipelinesStatusComponent from '../../pipelines/components/status'; +import ciBadge from './ci_badge_link.vue'; import PipelinesStageComponent from '../../pipelines/components/stage.vue'; import PipelinesUrlComponent from '../../pipelines/components/pipeline_url'; import PipelinesTimeagoComponent from '../../pipelines/components/time_ago'; @@ -39,7 +39,7 @@ export default { 'commit-component': CommitComponent, 'dropdown-stage': PipelinesStageComponent, 'pipeline-url': PipelinesUrlComponent, - 'status-scope': PipelinesStatusComponent, + ciBadge, 'time-ago': PipelinesTimeagoComponent, }, @@ -196,11 +196,20 @@ export default { return ''; }, + + pipelineStatus() { + if (this.pipeline.details && this.pipeline.details.status) { + return this.pipeline.details.status; + } + return {}; + }, }, template: ` <tr class="commit"> - <status-scope :pipeline="pipeline"/> + <td class="commit-link"> + <ci-badge :status="pipelineStatus"/> + </td> <pipeline-url :pipeline="pipeline"></pipeline-url> diff --git a/app/assets/javascripts/vue_shared/pipeline_svg_icons.js b/app/assets/javascripts/vue_shared/pipeline_svg_icons.js deleted file mode 100644 index 5af30ae74f0..00000000000 --- a/app/assets/javascripts/vue_shared/pipeline_svg_icons.js +++ /dev/null @@ -1,43 +0,0 @@ -import canceledSvg from 'icons/_icon_status_canceled.svg'; -import createdSvg from 'icons/_icon_status_created.svg'; -import failedSvg from 'icons/_icon_status_failed.svg'; -import manualSvg from 'icons/_icon_status_manual.svg'; -import pendingSvg from 'icons/_icon_status_pending.svg'; -import runningSvg from 'icons/_icon_status_running.svg'; -import skippedSvg from 'icons/_icon_status_skipped.svg'; -import successSvg from 'icons/_icon_status_success.svg'; -import warningSvg from 'icons/_icon_status_warning.svg'; - -import canceledBorderlessSvg from 'icons/_icon_status_canceled_borderless.svg'; -import createdBorderlessSvg from 'icons/_icon_status_created_borderless.svg'; -import failedBorderlessSvg from 'icons/_icon_status_failed_borderless.svg'; -import manualBorderlessSvg from 'icons/_icon_status_manual_borderless.svg'; -import pendingBorderlessSvg from 'icons/_icon_status_pending_borderless.svg'; -import runningBorderlessSvg from 'icons/_icon_status_running_borderless.svg'; -import skippedBorderlessSvg from 'icons/_icon_status_skipped_borderless.svg'; -import successBorderlessSvg from 'icons/_icon_status_success_borderless.svg'; -import warningBorderlessSvg from 'icons/_icon_status_warning_borderless.svg'; - -export const statusClassToSvgMap = { - icon_status_canceled: canceledSvg, - icon_status_created: createdSvg, - icon_status_failed: failedSvg, - icon_status_manual: manualSvg, - icon_status_pending: pendingSvg, - icon_status_running: runningSvg, - icon_status_skipped: skippedSvg, - icon_status_success: successSvg, - icon_status_warning: warningSvg, -}; - -export const statusClassToBorderlessSvgMap = { - icon_status_canceled: canceledBorderlessSvg, - icon_status_created: createdBorderlessSvg, - icon_status_failed: failedBorderlessSvg, - icon_status_manual: manualBorderlessSvg, - icon_status_pending: pendingBorderlessSvg, - icon_status_running: runningBorderlessSvg, - icon_status_skipped: skippedBorderlessSvg, - icon_status_success: successBorderlessSvg, - icon_status_warning: warningBorderlessSvg, -}; diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss index eb73f7cc794..678af978edd 100644 --- a/app/assets/stylesheets/framework/mobile.scss +++ b/app/assets/stylesheets/framework/mobile.scss @@ -112,7 +112,7 @@ } } - .issue_edited_ago, + .issue-edited-ago, .note_edited_ago { display: none; } diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 87592926930..d9ba1d4de4d 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -90,11 +90,6 @@ align-items: center; padding: $gl-padding-top $gl-padding 0; - i, - svg { - margin-right: 8px; - } - svg { position: relative; top: 1px; @@ -109,9 +104,10 @@ flex-wrap: wrap; } - .ci-status-icon > .icon-link svg { + .icon-link > .ci-status-icon > svg { width: 22px; height: 22px; + margin-right: 8px; } } diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index b199f18da1e..4cf645d6341 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -60,17 +60,24 @@ module IssuableActions end def bulk_update_params - params.require(:update).permit( + permitted_keys = [ :issuable_ids, :assignee_id, :milestone_id, :state_event, :subscription_event, - assignee_ids: [], label_ids: [], add_label_ids: [], remove_label_ids: [] - ) + ] + + if resource_name == 'issue' + permitted_keys << { assignee_ids: [] } + else + permitted_keys.unshift(:assignee_id) + end + + params.require(:update).permit(permitted_keys) end def resource_name diff --git a/app/controllers/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb index d4ab6782444..afd110adcad 100644 --- a/app/controllers/concerns/routable_actions.rb +++ b/app/controllers/concerns/routable_actions.rb @@ -4,7 +4,7 @@ module RoutableActions def find_routable!(routable_klass, requested_full_path, extra_authorization_proc: nil) routable = routable_klass.find_by_full_path(requested_full_path, follow_redirects: request.get?) - if routable_authorized?(routable_klass, routable, extra_authorization_proc) + if routable_authorized?(routable, extra_authorization_proc) ensure_canonical_path(routable, requested_full_path) routable else @@ -13,8 +13,8 @@ module RoutableActions end end - def routable_authorized?(routable_klass, routable, extra_authorization_proc) - action = :"read_#{routable_klass.to_s.underscore}" + def routable_authorized?(routable, extra_authorization_proc) + action = :"read_#{routable.class.to_s.underscore}" return false unless can?(current_user, action, routable) if extra_authorization_proc @@ -30,7 +30,7 @@ module RoutableActions canonical_path = routable.full_path if canonical_path != requested_path if canonical_path.casecmp(requested_path) != 0 - flash[:notice] = "Project '#{requested_path}' was moved to '#{canonical_path}'. Please update any links and bookmarks that may still have the old path." + flash[:notice] = "#{routable.class.to_s.titleize} '#{requested_path}' was moved to '#{canonical_path}'. Please update any links and bookmarks that may still have the old path." end redirect_to request.original_url.sub(requested_path, canonical_path) end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 7c0459648ef..760ba246e3e 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -208,7 +208,6 @@ class Projects::IssuesController < Projects::ApplicationController description: view_context.markdown_field(@issue, :description), description_text: @issue.description, task_status: @issue.task_status, - issue_number: @issue.iid, updated_at: @issue.updated_at } end diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index c85e96cf78d..206d0753f08 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -42,7 +42,10 @@ module ButtonHelper class: "btn #{css_class}", data: data, type: :button, - title: title + title: title, + aria: { + label: title + } end def http_clone_button(project, placement = 'right', append_link: true) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index fbbce6876c2..9290e4ec133 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -67,9 +67,10 @@ module IssuablesHelper end def users_dropdown_label(selected_users) - if selected_users.length == 0 + case selected_users.length + when 0 "Unassigned" - elsif selected_users.length == 1 + when 1 selected_users[0].name else "#{selected_users[0].name} + #{selected_users.length - 1} more" @@ -136,11 +137,9 @@ module IssuablesHelper author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "hidden-sm hidden-md hidden-lg") end - if issuable.tasks? - output << " ".html_safe - output << content_tag(:span, issuable.task_status, id: "task_status", class: "hidden-xs hidden-sm") - output << content_tag(:span, issuable.task_status_short, id: "task_status_short", class: "hidden-md hidden-lg") - end + output << " ".html_safe + output << content_tag(:span, issuable.task_status, id: "task_status", class: "hidden-xs hidden-sm") + output << content_tag(:span, issuable.task_status_short, id: "task_status_short", class: "hidden-md hidden-lg") output end diff --git a/app/models/commit.rb b/app/models/commit.rb index 3a143a5a1f6..8d54ce6eb25 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -326,10 +326,7 @@ class Commit end def raw_diffs(*args) - use_gitaly = Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs) - deltas_only = args.last.is_a?(Hash) && args.last[:deltas_only] - - if use_gitaly && !deltas_only + if Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs) Gitlab::GitalyClient::Commit.diff_from_parent(self, *args) else raw.diffs(*args) diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index 6eddeab515e..c034bf9cbc0 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -44,14 +44,15 @@ module Mentionable end def all_references(current_user = nil, extractor: nil) + @extractors ||= {} + # Use custom extractor if it's passed in the function parameters. if extractor - @extractor = extractor + @extractors[current_user] = extractor else - @extractor ||= Gitlab::ReferenceExtractor. - new(project, current_user) + extractor = @extractors[current_user] ||= Gitlab::ReferenceExtractor.new(project, current_user) - @extractor.reset_memoized_values + extractor.reset_memoized_values end self.class.mentionable_attrs.each do |attr, options| @@ -62,10 +63,10 @@ module Mentionable skip_project_check: skip_project_check? ) - @extractor.analyze(text, options) + extractor.analyze(text, options) end - @extractor + extractor end def mentioned_users(current_user = nil) diff --git a/app/models/concerns/protected_branch_access.rb b/app/models/concerns/protected_branch_access.rb index c41b807df8a..a40148a4394 100644 --- a/app/models/concerns/protected_branch_access.rb +++ b/app/models/concerns/protected_branch_access.rb @@ -7,5 +7,27 @@ module ProtectedBranchAccess belongs_to :protected_branch delegate :project, to: :protected_branch + + validates :access_level, presence: true, inclusion: { + in: [ + Gitlab::Access::MASTER, + Gitlab::Access::DEVELOPER, + Gitlab::Access::NO_ACCESS + ] + } + + def self.human_access_levels + { + Gitlab::Access::MASTER => "Masters", + Gitlab::Access::DEVELOPER => "Developers + Masters", + Gitlab::Access::NO_ACCESS => "No one" + }.with_indifferent_access + end + + def check_access(user) + return false if access_level == Gitlab::Access::NO_ACCESS + + super + end end end diff --git a/app/models/protected_branch/merge_access_level.rb b/app/models/protected_branch/merge_access_level.rb index 771e3376613..e8d35ac326f 100644 --- a/app/models/protected_branch/merge_access_level.rb +++ b/app/models/protected_branch/merge_access_level.rb @@ -1,13 +1,3 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base include ProtectedBranchAccess - - validates :access_level, presence: true, inclusion: { in: [Gitlab::Access::MASTER, - Gitlab::Access::DEVELOPER] } - - def self.human_access_levels - { - Gitlab::Access::MASTER => "Masters", - Gitlab::Access::DEVELOPER => "Developers + Masters" - }.with_indifferent_access - end end diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb index 14610cb42b7..7a2e9e5ec5d 100644 --- a/app/models/protected_branch/push_access_level.rb +++ b/app/models/protected_branch/push_access_level.rb @@ -1,21 +1,3 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base include ProtectedBranchAccess - - validates :access_level, presence: true, inclusion: { in: [Gitlab::Access::MASTER, - Gitlab::Access::DEVELOPER, - Gitlab::Access::NO_ACCESS] } - - def self.human_access_levels - { - Gitlab::Access::MASTER => "Masters", - Gitlab::Access::DEVELOPER => "Developers + Masters", - Gitlab::Access::NO_ACCESS => "No one" - }.with_indifferent_access - end - - def check_access(user) - return false if access_level == Gitlab::Access::NO_ACCESS - - super - end end diff --git a/app/models/repository.rb b/app/models/repository.rb index 11163ec77e9..b1563bfba8b 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -517,8 +517,8 @@ class Repository cache_method :avatar def readme - if head = tree(:head) - ReadmeBlob.new(head.readme, self) + if readme = tree(:head)&.readme + ReadmeBlob.new(readme, self) end end diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb index 40ff9b8b867..5d42a89fced 100644 --- a/app/services/issuable/bulk_update_service.rb +++ b/app/services/issuable/bulk_update_service.rb @@ -7,7 +7,7 @@ module Issuable ids = params.delete(:issuable_ids).split(",") items = model_class.where(id: ids) - %i(state_event milestone_id assignee_id assignee_ids add_label_ids remove_label_ids subscription_event).each do |key| + permitted_attrs(type).each do |key| params.delete(key) unless params[key].present? end @@ -26,5 +26,17 @@ module Issuable success: !items.count.zero? } end + + private + + def permitted_attrs(type) + attrs = %i(state_event milestone_id assignee_id assignee_ids add_label_ids remove_label_ids subscription_event) + + if type == 'issue' + attrs.push(:assignee_ids) + else + attrs.push(:assignee_id) + end + end end end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 174e7c6e95b..0766df50ed2 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -79,7 +79,7 @@ module SystemNoteService text_parts.join(' and ') elsif old_assignees.any? - "removed all assignees" + "removed assignee" elsif issue.assignees.any? "assigned to #{issue.assignees.map(&:to_reference).to_sentence}" end diff --git a/app/views/import/fogbugz/new_user_map.html.haml b/app/views/import/fogbugz/new_user_map.html.haml index 9999a4362c6..c52a515226e 100644 --- a/app/views/import/fogbugz/new_user_map.html.haml +++ b/app/views/import/fogbugz/new_user_map.html.haml @@ -46,6 +46,3 @@ .form-actions = submit_tag 'Continue to the next step', class: 'btn btn-create' - -:javascript - new UsersSelect(); diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index 0e64ebd71b8..b689991bb6d 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -13,7 +13,7 @@ .location-badge= label .search-input-wrap .dropdown{ data: { url: search_autocomplete_path } } - = search_field_tag 'search', nil, placeholder: 'Search', class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options', spellcheck: false, tabindex: '1', autocomplete: 'off', data: { toggle: 'dropdown', issues_path: issues_dashboard_url, mr_path: merge_requests_dashboard_url } + = search_field_tag 'search', nil, placeholder: 'Search', class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options', spellcheck: false, tabindex: '1', autocomplete: 'off', data: { toggle: 'dropdown', issues_path: issues_dashboard_url, mr_path: merge_requests_dashboard_url }, aria: { label: 'Search' } .dropdown-menu.dropdown-select = dropdown_content do %ul diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml index 796ecdfd014..55575c5e412 100644 --- a/app/views/projects/branches/new.html.haml +++ b/app/views/projects/branches/new.html.haml @@ -17,11 +17,13 @@ .help-block.text-danger.js-branch-name-error .form-group = label_tag :ref, 'Create from', class: 'control-label' - .col-sm-10.dropdown.create-from - = hidden_field_tag :ref, default_ref - = button_tag type: 'button', title: default_ref, class: 'dropdown-toggle form-control js-branch-select', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do - .text-left.dropdown-toggle-text= default_ref - = render 'shared/ref_dropdown', dropdown_class: 'wide' + .col-sm-10.create-from + .dropdown + = hidden_field_tag :ref, default_ref + = button_tag type: 'button', title: default_ref, class: 'dropdown-menu-toggle wide form-control js-branch-select', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do + .text-left.dropdown-toggle-text= default_ref + = icon('chevron-down') + = render 'shared/ref_dropdown', dropdown_class: 'wide' .help-block Existing branch name, tag, or commit SHA .form-actions = button_tag 'Create branch', class: 'btn btn-create', tabindex: 3 diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 8f32d2b72e5..24153b8b59a 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -37,6 +37,6 @@ .commit-actions.flex-row.hidden-xs - if commit.status(ref) = render_commit_status(commit, ref: ref) - = clipboard_button(text: commit.id, title: "Copy commit SHA to clipboard") = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent" + = clipboard_button(text: commit.id, title: "Copy commit SHA to clipboard") = link_to_browse_code(project, commit) diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 160345cfaa5..d9643dc7957 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -40,8 +40,8 @@ .form_group.prepend-top-20.sharing-and-permissions .row.js-visibility-select .col-md-9 - %label.label-light - = label_tag :project_visibility, 'Project Visibility', class: 'label-light' + .label-light + = label_tag :project_visibility, 'Project Visibility', class: 'label-light', for: :project_visibility_level = link_to "(?)", help_page_path("public_access/public_access") %span.help-block .col-md-3.visibility-select-container diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 9084883eb3e..f66724900de 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -51,12 +51,17 @@ .issue-details.issuable-details .detail-page-description.content-block - .issue-title-data.hidden{ "data" => { "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue), - "can-update-tasks-class" => can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '', + #js-issuable-app{ "data" => { "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue), + "can-update" => can?(current_user, :update_issue, @issue).to_s, + "issuable-ref" => @issue.to_reference, } } - .issue-title-entrypoint + %h2.title= markdown_field(@issue, :title) + - if @issue.description.present? + .description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' } + .wiki= markdown_field(@issue, :description) + %textarea.hidden.js-task-list-field= @issue.description - = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue_edited_ago') + = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago') #merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } } // This element is filled in using JavaScript. diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 9e292729425..e180cb8bad1 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -30,7 +30,7 @@ #{root_url}#{current_user.username}/ = f.hidden_field :namespace_id, value: current_user.namespace_id .form-group.col-xs-12.col-sm-6.project-path - = f.label :namespace_id, class: 'label-light' do + = f.label :path, class: 'label-light' do %span Project name = f.text_field :path, placeholder: "my-awesome-project", class: "form-control", tabindex: 2, autofocus: true, required: true diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml index 34a4d7398bc..0992a65f7cd 100644 --- a/app/views/shared/_clone_panel.html.haml +++ b/app/views/shared/_clone_panel.html.haml @@ -17,7 +17,7 @@ %li = http_clone_button(project) - = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true + = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: 'Project clone URL' } .input-group-btn = clipboard_button(target: '#project_clone', title: "Copy URL to clipboard") diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml index 36bbb1148d4..217af7c9fac 100644 --- a/app/views/shared/issuable/_assignees.html.haml +++ b/app/views/shared/issuable/_assignees.html.haml @@ -1,9 +1,8 @@ - max_render = 3 - max = [max_render, issue.assignees.length].min -- issue.assignees.each_with_index do |assignee, index| - - if index < max - = link_to_member(@project, assignee, name: false, title: "Assigned to :name") +- issue.assignees.take(max).each do |assignee| + = link_to_member(@project, assignee, name: false, title: "Assigned to :name") - if issue.assignees.length > max_render - counter = issue.assignees.length - max_render diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index 1a12f110945..6cd03f028a9 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -71,7 +71,6 @@ = render 'shared/labels_row', labels: @labels :javascript - new UsersSelect(); new LabelsSelect(); new MilestoneSelect(); new IssueStatusSelect(); diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index f7b87171573..622e2f33eea 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -150,7 +150,6 @@ - unless type === :boards_modal :javascript - new UsersSelect(); new LabelsSelect(); new MilestoneSelect(); new IssueStatusSelect(); diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index c36a45098a8..e9ce7b7ce9c 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -1,4 +1,4 @@ -- if issuable.instance_of?(Issue) +- if issuable.is_a?(Issue) #js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]" } } - else .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } @@ -33,17 +33,17 @@ - options = { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_ids][]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } } - - if issuable.instance_of?(Issue) - - if issuable.assignees.length == 0 + - title = 'Select assignee' + + - if issuable.is_a?(Issue) + - unless issuable.assignees.any? = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil - - title = 'Select assignee' - options[:toggle_class] += ' js-multiselect js-save-user-data' - - options[:data][:field_name] = "#{issuable.to_ability_name}[assignee_ids][]" - - options[:data][:multi_select] = true - - options[:data]['dropdown-title'] = title - - options[:data]['dropdown-header'] = 'Assignee' - - options[:data]['max-select'] = 1 - - else - - title = 'Select assignee' + - data = { field_name: "#{issuable.to_ability_name}[assignee_ids][]" } + - data[:multi_select] = true + - data['dropdown-title'] = title + - data['dropdown-header'] = 'Assignee' + - data['max-select'] = 1 + - options[:data].merge!(data) = dropdown_tag(title, options: options) diff --git a/app/views/shared/issuable/form/_issue_assignee.html.haml b/app/views/shared/issuable/form/_issue_assignee.html.haml index c33474ac3b4..66091d95a91 100644 --- a/app/views/shared/issuable/form/_issue_assignee.html.haml +++ b/app/views/shared/issuable/form/_issue_assignee.html.haml @@ -1,8 +1,9 @@ - issue = issuable +- assignees = issue.assignees .block.assignee .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee_list) } - - if issue.assignees.any? - - issue.assignees.each do |assignee| + - if assignees.any? + - assignees.each do |assignee| = link_to_member(@project, assignee, size: 24) - else = icon('user', 'aria-hidden': 'true') @@ -12,8 +13,8 @@ - if can_edit_issuable = link_to 'Edit', '#', class: 'edit-link pull-right' .value.hide-collapsed - - if issue.assignees.any? - - issue.assignees.each do |assignee| + - if assignees.any? + - assignees.each do |assignee| = link_to_member(@project, assignee, size: 32, extra_class: 'bold') do %span.username = assignee.to_reference |