Commit 524c3c88 authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch 'master' into fl-document-key-vue

* master: (41 commits)
  Remove cycle analytics bundle webpack entry point
  Replace whitespace demonstrating line-breaks in GFM docs
  Remove pipelines mini graph spec that wasn't actually testing anything
  Change the wording to reduce confusion about whether you need to reconfigure or restart.
  Add Assignees vue component missing data container
  Set security harness hook to executable after creation
  Fix for open-ended parameter's in lograge causing elastic memory issues
  Docs: explain feature availability in GitLab.com
  Patch 29
  PagesDomain: improve breadcrumbs
  PagesDomain: Add edit/update functionality
  Add new job variables: CI_RUNNER_{REVISION,VERSION,EXECUTABLE_ARCH}
  Re-enable the remaning Wiki Gitaly-Endpoints
  Labels Select Tests
  Fix unnecessary spacing between labels
  Add changelog entry
  Make label filter URL dynamic
  Move the remaining EE-specific JS files to ee/
  Specify base controller for Doorkeeper
  Fix example in Vuex docs
  ...
parents f87d7e90 ccbce7af
<!---
Please read this! Please read this!
Before opening a new issue, make sure to search for keywords in the issues Before opening a new issue, make sure to search for keywords in the issues
...@@ -14,10 +15,7 @@ For the Enterprise Edition issue tracker: ...@@ -14,10 +15,7 @@ For the Enterprise Edition issue tracker:
- https://gitlab.com/gitlab-org/gitlab-ee/issues?label_name%5B%5D=bug - https://gitlab.com/gitlab-org/gitlab-ee/issues?label_name%5B%5D=bug
and verify the issue you're about to submit isn't a duplicate. and verify the issue you're about to submit isn't a duplicate.
--->
Please remove this notice if you're confident your issue isn't a duplicate.
------
### Summary ### Summary
......
...@@ -3,10 +3,10 @@ ...@@ -3,10 +3,10 @@
import AccessorUtilities from './lib/utils/accessor'; import AccessorUtilities from './lib/utils/accessor';
export default class Autosave { export default class Autosave {
constructor(field, key, resource) { constructor(field, key) {
this.field = field; this.field = field;
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
this.resource = resource;
if (key.join != null) { if (key.join != null) {
key = key.join('/'); key = key.join('/');
} }
...@@ -17,31 +17,27 @@ export default class Autosave { ...@@ -17,31 +17,27 @@ export default class Autosave {
} }
restore() { restore() {
var text;
if (!this.isLocalStorageAvailable) return; if (!this.isLocalStorageAvailable) return;
if (!this.field.length) return;
text = window.localStorage.getItem(this.key); const text = window.localStorage.getItem(this.key);
if ((text != null ? text.length : void 0) > 0) { if ((text != null ? text.length : void 0) > 0) {
this.field.val(text); this.field.val(text);
} }
if (!this.resource && this.resource !== 'issue') {
this.field.trigger('input'); this.field.trigger('input');
} else {
// v-model does not update with jQuery trigger // v-model does not update with jQuery trigger
// https://github.com/vuejs/vue/issues/2804#issuecomment-216968137 // https://github.com/vuejs/vue/issues/2804#issuecomment-216968137
const event = new Event('change', { bubbles: true, cancelable: false }); const event = new Event('change', { bubbles: true, cancelable: false });
const field = this.field.get(0); const field = this.field.get(0);
if (field) {
field.dispatchEvent(event); field.dispatchEvent(event);
} }
}
}
save() { save() {
var text; if (!this.field.length) return;
text = this.field.val();
const text = this.field.val();
if (this.isLocalStorageAvailable && (text != null ? text.length : void 0) > 0) { if (this.isLocalStorageAvailable && (text != null ? text.length : void 0) > 0) {
return window.localStorage.setItem(this.key, text); return window.localStorage.setItem(this.key, text);
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import _ from 'underscore'; import _ from 'underscore';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { __ } from './locale'; import { __ } from './locale';
import { isInIssuePage, updateTooltipTitle } from './lib/utils/common_utils'; import { isInIssuePage, isInMRPage, hasVueMRDiscussionsCookie, updateTooltipTitle } from './lib/utils/common_utils';
import flash from './flash'; import flash from './flash';
import axios from './lib/utils/axios_utils'; import axios from './lib/utils/axios_utils';
...@@ -239,9 +239,9 @@ class AwardsHandler { ...@@ -239,9 +239,9 @@ class AwardsHandler {
} }
addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) { addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) {
const isMainAwardsBlock = votesBlock.closest('.js-issue-note-awards').length; const isMainAwardsBlock = votesBlock.closest('.js-noteable-awards').length;
if (isInIssuePage() && !isMainAwardsBlock) { if (this.isInVueNoteablePage() && !isMainAwardsBlock) {
const id = votesBlock.attr('id').replace('note_', ''); const id = votesBlock.attr('id').replace('note_', '');
this.hideMenuElement($('.emoji-menu')); this.hideMenuElement($('.emoji-menu'));
...@@ -293,8 +293,16 @@ class AwardsHandler { ...@@ -293,8 +293,16 @@ class AwardsHandler {
} }
} }
isVueMRDiscussions() {
return isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible');
}
isInVueNoteablePage() {
return isInIssuePage() || this.isVueMRDiscussions();
}
getVotesBlock() { getVotesBlock() {
if (isInIssuePage()) { if (this.isInVueNoteablePage()) {
const $el = $('.js-add-award.is-active').closest('.note.timeline-entry'); const $el = $('.js-add-award.is-active').closest('.note.timeline-entry');
if ($el.length) { if ($el.length) {
......
...@@ -6,7 +6,7 @@ import { __ } from '../../locale'; ...@@ -6,7 +6,7 @@ import { __ } from '../../locale';
import Sidebar from '../../right_sidebar'; import Sidebar from '../../right_sidebar';
import eventHub from '../../sidebar/event_hub'; import eventHub from '../../sidebar/event_hub';
import assigneeTitle from '../../sidebar/components/assignees/assignee_title'; import assigneeTitle from '../../sidebar/components/assignees/assignee_title';
import assignees from '../../sidebar/components/assignees/assignees'; import assignees from '../../sidebar/components/assignees/assignees.vue';
import DueDateSelectors from '../../due_date_select'; import DueDateSelectors from '../../due_date_select';
import './sidebar/remove_issue'; import './sidebar/remove_issue';
import IssuableContext from '../../issuable_context'; import IssuableContext from '../../issuable_context';
......
...@@ -14,10 +14,10 @@ import CycleAnalyticsStore from './cycle_analytics_store'; ...@@ -14,10 +14,10 @@ import CycleAnalyticsStore from './cycle_analytics_store';
Vue.use(Translate); Vue.use(Translate);
$(() => { export default () => {
const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed'; const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
gl.cycleAnalyticsApp = new Vue({ new Vue({ // eslint-disable-line no-new
el: '#cycle-analytics', el: '#cycle-analytics',
name: 'CycleAnalytics', name: 'CycleAnalytics',
components: { components: {
...@@ -132,4 +132,4 @@ $(() => { ...@@ -132,4 +132,4 @@ $(() => {
}, },
}, },
}); });
}); };
...@@ -197,7 +197,7 @@ const JumpToDiscussion = Vue.extend({ ...@@ -197,7 +197,7 @@ const JumpToDiscussion = Vue.extend({
} }
$.scrollTo($target, { $.scrollTo($target, {
offset: 0 offset: -150
}); });
} }
}, },
......
...@@ -87,6 +87,7 @@ const ResolveBtn = Vue.extend({ ...@@ -87,6 +87,7 @@ const ResolveBtn = Vue.extend({
CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by); CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by);
this.discussion.updateHeadline(data); this.discussion.updateHeadline(data);
gl.mrWidget.checkStatus(); gl.mrWidget.checkStatus();
document.dispatchEvent(new CustomEvent('refreshVueNotes'));
this.updateTooltip(); this.updateTooltip();
}) })
......
...@@ -14,6 +14,7 @@ import './components/resolve_count'; ...@@ -14,6 +14,7 @@ import './components/resolve_count';
import './components/resolve_discussion_btn'; import './components/resolve_discussion_btn';
import './components/diff_note_avatars'; import './components/diff_note_avatars';
import './components/new_issue_for_discussion'; import './components/new_issue_for_discussion';
import { hasVueMRDiscussionsCookie } from '../lib/utils/common_utils';
export default () => { export default () => {
const projectPathHolder = document.querySelector('.merge-request') || document.querySelector('.commit-box'); const projectPathHolder = document.querySelector('.merge-request') || document.querySelector('.commit-box');
...@@ -67,12 +68,14 @@ export default () => { ...@@ -67,12 +68,14 @@ export default () => {
gl.diffNotesCompileComponents(); gl.diffNotesCompileComponents();
if (!hasVueMRDiscussionsCookie()) {
new Vue({ new Vue({
el: '#resolve-count-app', el: '#resolve-count-app',
components: { components: {
'resolve-count': ResolveCount 'resolve-count': ResolveCount
}, },
}); });
}
$(window).trigger('resize.nav'); $(window).trigger('resize.nav');
}; };
...@@ -8,8 +8,8 @@ window.gl = window.gl || {}; ...@@ -8,8 +8,8 @@ window.gl = window.gl || {};
class ResolveServiceClass { class ResolveServiceClass {
constructor(root) { constructor(root) {
this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve`); this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve?html=true`);
this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve`); this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve?html=true`);
} }
resolve(noteId) { resolve(noteId) {
...@@ -45,6 +45,7 @@ class ResolveServiceClass { ...@@ -45,6 +45,7 @@ class ResolveServiceClass {
if (gl.mrWidget) gl.mrWidget.checkStatus(); if (gl.mrWidget) gl.mrWidget.checkStatus();
discussion.updateHeadline(data); discussion.updateHeadline(data);
document.dispatchEvent(new CustomEvent('refreshVueNotes'));
}) })
.catch(() => new Flash('An error occurred when trying to resolve a discussion. Please try again.')); .catch(() => new Flash('An error occurred when trying to resolve a discussion. Please try again.'));
} }
......
<script> <script>
import { mapGetters, mapState, mapActions } from 'vuex'; import { mapGetters, mapState, mapActions } from 'vuex';
import icon from '~/vue_shared/components/icon.vue';
import panelResizer from '~/vue_shared/components/panel_resizer.vue';
import repoCommitSection from './repo_commit_section.vue'; import repoCommitSection from './repo_commit_section.vue';
import icon from '../../vue_shared/components/icon.vue';
import panelResizer from '../../vue_shared/components/panel_resizer.vue';
export default { export default {
components: { components: {
......
<script> <script>
import icon from '~/vue_shared/components/icon.vue';
import repoTree from './ide_repo_tree.vue'; import repoTree from './ide_repo_tree.vue';
import icon from '../../vue_shared/components/icon.vue';
import newDropdown from './new_dropdown/index.vue'; import newDropdown from './new_dropdown/index.vue';
export default { export default {
......
<script> <script>
import projectAvatarImage from '~/vue_shared/components/project_avatar/image.vue';
import branchesTree from './ide_project_branches_tree.vue'; import branchesTree from './ide_project_branches_tree.vue';
import projectAvatarImage from '../../vue_shared/components/project_avatar/image.vue';
export default { export default {
components: { components: {
......
<script> <script>
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
import repoPreviousDirectory from './repo_prev_directory.vue'; import repoPreviousDirectory from './repo_prev_directory.vue';
import repoFile from './repo_file.vue'; import repoFile from './repo_file.vue';
import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue';
import { treeList } from '../stores/utils'; import { treeList } from '../stores/utils';
export default { export default {
......
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import icon from '~/vue_shared/components/icon.vue';
import panelResizer from '~/vue_shared/components/panel_resizer.vue';
import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
import projectTree from './ide_project_tree.vue'; import projectTree from './ide_project_tree.vue';
import icon from '../../vue_shared/components/icon.vue';
import panelResizer from '../../vue_shared/components/panel_resizer.vue';
import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue';
export default { export default {
components: { components: {
......
<script> <script>
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import icon from '../../vue_shared/components/icon.vue'; import icon from '~/vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import timeAgoMixin from '../../vue_shared/mixins/timeago'; import timeAgoMixin from '~/vue_shared/mixins/timeago';
export default { export default {
components: { components: {
......
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import flash, { hideFlash } from '../../flash'; import flash, { hideFlash } from '~/flash';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import loadingIcon from '~/vue_shared/components/loading_icon.vue';
export default { export default {
components: { components: {
......
<script> <script>
import { mapGetters, mapState, mapActions } from 'vuex'; import { mapGetters, mapState, mapActions } from 'vuex';
import tooltip from '../../vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import icon from '../../vue_shared/components/icon.vue'; import icon from '~/vue_shared/components/icon.vue';
import modal from '../../vue_shared/components/modal.vue'; import modal from '~/vue_shared/components/modal.vue';
import commitFilesList from './commit_sidebar/list.vue'; import commitFilesList from './commit_sidebar/list.vue';
export default { export default {
......
<script> <script>
import { mapGetters, mapActions, mapState } from 'vuex'; import { mapGetters, mapActions, mapState } from 'vuex';
import modal from '../../vue_shared/components/modal.vue'; import modal from '~/vue_shared/components/modal.vue';
export default { export default {
components: { components: {
......
<script> <script>
/* global monaco */ /* global monaco */
import { mapState, mapGetters, mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex';
import flash from '../../flash'; import flash from '~/flash';
import monacoLoader from '../monaco_loader'; import monacoLoader from '../monaco_loader';
import Editor from '../lib/editor'; import Editor from '../lib/editor';
......
<script> <script>
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue'; import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
export default { export default {
components: { components: {
......
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import LineHighlighter from '../../line_highlighter'; import LineHighlighter from '~/line_highlighter';
import syntaxHighlight from '../../syntax_highlight'; import syntaxHighlight from '~/syntax_highlight';
export default { export default {
computed: { computed: {
......
import Vue from 'vue'; import Vue from 'vue';
import { visitUrl } from '../../lib/utils/url_utility'; import { visitUrl } from '~/lib/utils/url_utility';
import flash from '../../flash'; import flash from '~/flash';
import service from '../services'; import service from '../services';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { stripHtml } from '../../lib/utils/text_utility'; import { stripHtml } from '../../lib/utils/text_utility';
......
...@@ -21,7 +21,7 @@ export default class LabelsSelect { ...@@ -21,7 +21,7 @@ export default class LabelsSelect {
} }
$els.each(function(i, dropdown) { $els.each(function(i, dropdown) {
var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, namespacePath, projectPath, saveLabelData, selectedLabel, showAny, showNo, $sidebarLabelTooltip, initialSelected, $toggleText, fieldName, useId, propertyName, showMenuAbove, $container, $dropdownContainer; var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelUrl, namespacePath, projectPath, saveLabelData, selectedLabel, showAny, showNo, $sidebarLabelTooltip, initialSelected, $toggleText, fieldName, useId, propertyName, showMenuAbove, $container, $dropdownContainer;
$dropdown = $(dropdown); $dropdown = $(dropdown);
$dropdownContainer = $dropdown.closest('.labels-filter'); $dropdownContainer = $dropdown.closest('.labels-filter');
$toggleText = $dropdown.find('.dropdown-toggle-text'); $toggleText = $dropdown.find('.dropdown-toggle-text');
...@@ -53,13 +53,6 @@ export default class LabelsSelect { ...@@ -53,13 +53,6 @@ export default class LabelsSelect {
.map(function () { .map(function () {
return this.value; return this.value;
}).get(); }).get();
if (issueUpdateURL != null) {
issueURLSplit = issueUpdateURL.split('/');
}
if (issueUpdateURL) {
labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %> <a href="<%- ["",issueURLSplit[1], issueURLSplit[2],""].join("/") %>issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;"> <%- label.title %> </span> </a> <% }); %>');
labelNoneHTMLTemplate = '<span class="no-value">None</span>';
}
const handleClick = options.handleClick; const handleClick = options.handleClick;
$sidebarLabelTooltip.tooltip(); $sidebarLabelTooltip.tooltip();
...@@ -91,14 +84,17 @@ export default class LabelsSelect { ...@@ -91,14 +84,17 @@ export default class LabelsSelect {
$loading.fadeOut(); $loading.fadeOut();
$dropdown.trigger('loaded.gl.dropdown'); $dropdown.trigger('loaded.gl.dropdown');
$selectbox.hide(); $selectbox.hide();
data.issueURLSplit = issueURLSplit; data.issueUpdateURL = issueUpdateURL;
labelCount = 0; labelCount = 0;
if (data.labels.length) { if (data.labels.length && issueUpdateURL) {
template = labelHTMLTemplate(data); template = LabelsSelect.getLabelTemplate({
labels: data.labels,
issueUpdateURL,
});
labelCount = data.labels.length; labelCount = data.labels.length;
} }
else { else {
template = labelNoneHTMLTemplate; template = '<span class="no-value">None</span>';
} }
$value.removeAttr('style').html(template); $value.removeAttr('style').html(template);
$sidebarCollapsedValue.text(labelCount); $sidebarCollapsedValue.text(labelCount);
...@@ -418,6 +414,26 @@ export default class LabelsSelect { ...@@ -418,6 +414,26 @@ export default class LabelsSelect {
this.bindEvents(); this.bindEvents();
} }
static getLabelTemplate(tplData) {
// We could use ES6 template string here
// and properly indent markup for readability
// but that also introduces unintended white-space
// so best approach is to use traditional way of
// concatenation
// see: http://2ality.com/2016/05/template-literal-whitespace.html#joining-arrays
const tpl = _.template([
'<% _.each(labels, function(label){ %>',
'<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>">',
'<span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;">',
'<%- label.title %>',
'</span>',
'</a>',
'<% }); %>',
].join(''));
return tpl(tplData);
}
bindEvents() { bindEvents() {
return $('body').on('change', '.selected_issue', this.onSelectCheckboxIssue); return $('body').on('change', '.selected_issue', this.onSelectCheckboxIssue);
} }
......
import jQuery from 'jquery';
import Cookies from 'js-cookie';
import axios from './axios_utils'; import axios from './axios_utils';
import { getLocationHash } from './url_utility'; import { getLocationHash } from './url_utility';
import { convertToCamelCase } from './text_utility'; import { convertToCamelCase } from './text_utility';
...@@ -22,13 +24,18 @@ export const getGroupSlug = () => { ...@@ -22,13 +24,18 @@ export const getGroupSlug = () => {
return null; return null;
}; };
export const isInIssuePage = () => { export const checkPageAndAction = (page, action) => {
const page = getPagePath(1); const pagePath = getPagePath(1);
const action = getPagePath(2); const actionPath = getPagePath(2);
return page === 'issues' && action === 'show'; return pagePath === page && actionPath === action;
}; };
export const isInIssuePage = () => checkPageAndAction('issues', 'show');
export const isInMRPage = () => checkPageAndAction('merge_requests', 'show');
export const isInNoteablePage = () => isInIssuePage() || isInMRPage();
export const hasVueMRDiscussionsCookie = () => Cookies.get('vue_mr_discussions');
export const ajaxGet = url => axios.get(url, { export const ajaxGet = url => axios.get(url, {
params: { format: 'js' }, params: { format: 'js' },
responseType: 'text', responseType: 'text',
...@@ -133,7 +140,11 @@ export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; ...@@ -133,7 +140,11 @@ export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
// 3) Middle-click or Mouse Wheel Click (e.which is 2) // 3) Middle-click or Mouse Wheel Click (e.which is 2)
export const isMetaClick = e => e.metaKey || e.ctrlKey || e.which === 2; export const isMetaClick = e => e.metaKey || e.ctrlKey || e.which === 2;
export const scrollToElement = ($el) => { export const scrollToElement = (element) => {
let $el = element;
if (!(element instanceof jQuery)) {
$el = $(element);
}
const top = $el.offset().top; const top = $el.offset().top;
const mrTabsHeight = $('.merge-request-tabs').height() || 0; const mrTabsHeight = $('.merge-request-tabs').height() || 0;
const headerHeight = $('.navbar-gitlab').height() || 0; const headerHeight = $('.navbar-gitlab').height() || 0;
......
...@@ -65,6 +65,20 @@ export function capitalizeFirstCharacter(text) { ...@@ -65,6 +65,20 @@ export function capitalizeFirstCharacter(text) {
return `${text[0].toUpperCase()}${text.slice(1)}`; return `${text[0].toUpperCase()}${text.slice(1)}`;
} }
export function camelCase(str) {
return str.replace(/_+([a-z])/gi, ($1, $2) => $2.toUpperCase());
}
export function camelCaseKeys(obj = {}) {
return Object.keys(obj).reduce((acc, key) => {
const camelKey = camelCase(key);
return {
...acc,
[camelKey]: obj[key],
};
}, {});
}
/** /**
* Replaces all html tags from a string with the given replacement. * Replaces all html tags from a string with the given replacement.
* *
......
...@@ -12,7 +12,7 @@ import './components/inline_conflict_lines'; ...@@ -12,7 +12,7 @@ import './components/inline_conflict_lines';
import './components/parallel_conflict_lines'; import './components/parallel_conflict_lines';
import syntaxHighlight from '../syntax_highlight'; import syntaxHighlight from '../syntax_highlight';
$(() => { export default function initMergeConflicts() {
const INTERACTIVE_RESOLVE_MODE = 'interactive'; const INTERACTIVE_RESOLVE_MODE = 'interactive';
const conflictsEl = document.querySelector('#conflicts'); const conflictsEl = document.querySelector('#conflicts');
const mergeConflictsStore = gl.mergeConflicts.mergeConflictsStore; const mergeConflictsStore = gl.mergeConflicts.mergeConflictsStore;
...@@ -91,4 +91,4 @@ $(() => { ...@@ -91,4 +91,4 @@ $(() => {
} }
} }
}); });
}); }
...@@ -241,6 +241,10 @@ export default class MergeRequestTabs { ...@@ -241,6 +241,10 @@ export default class MergeRequestTabs {
return newState; return newState;
} }
getCurrentAction() {
return this.currentAction;
}
loadCommits(source) { loadCommits(source) {
if (this.commitsLoaded) { if (this.commitsLoaded) {
return; return;
......
import Vue from 'vue';
import notesApp from '../notes/components/notes_app.vue';
import discussionCounter from '../notes/components/discussion_counter.vue';
import store from '../notes/stores';
document.addEventListener('DOMContentLoaded', () => {
new Vue({ // eslint-disable-line
el: '#js-vue-mr-discussions',
components: {
notesApp,
},
data() {
const notesDataset = document.getElementById('js-vue-mr-discussions').dataset;
return {
noteableData: JSON.parse(notesDataset.noteableData),
currentUserData: JSON.parse(notesDataset.currentUserData),
notesData: JSON.parse(notesDataset.notesData),
};
},
render(createElement) {
return createElement('notes-app', {
props: {
noteableData: this.noteableData,
notesData: this.notesData,
userData: this.currentUserData,
},
});
},
});
new Vue({ // eslint-disable-line
el: '#js-vue-discussion-counter',
components: {
discussionCounter,
},
store,
render(createElement) {
return createElement('discussion-counter');
},
});
});
This diff is collapsed.
...@@ -2,10 +2,11 @@ ...@@ -2,10 +2,11 @@
import { mapActions, mapGetters } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import _ from 'underscore'; import _ from 'underscore';
import Autosize from 'autosize'; import Autosize from 'autosize';
import { __ } from '~/locale'; import { __, sprintf } from '~/locale';
import Flash from '../../flash'; import Flash from '../../flash';
import Autosave from '../../autosave'; import Autosave from '../../autosave';
import TaskList from '../../task_list'; import TaskList from '../../task_list';
import { capitalizeFirstCharacter, convertToCamelCase } from '../../lib/utils/text_utility';
import * as constants from '../constants'; import * as constants from '../constants';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
...@@ -29,6 +30,12 @@ ...@@ -29,6 +30,12 @@
mixins: [ mixins: [
issuableStateMixin, issuableStateMixin,
], ],
props: {
noteableType: {
type: String,
required: true,
},
},
data() { data() {
return { return {
note: '', note: '',
...@@ -43,37 +50,51 @@ ...@@ -43,37 +50,51 @@
'getUserData', 'getUserData',
'getNoteableData', 'getNoteableData',
'getNotesData', 'getNotesData',
'issueState', 'openState',
]), ]),
noteableDisplayName() {
return this.noteableType.replace(/_/g, ' ');
},
isLoggedIn() { isLoggedIn() {
return this.getUserData.id; return this.getUserData.id;
}, },
commentButtonTitle() { commentButtonTitle() {
return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion'; return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion';
}, },
isIssueOpen() { isOpen() {
return this.issueState === constants.OPENED || this.issueState === constants.REOPENED; return this.openState === constants.OPENED || this.openState === constants.REOPENED;
}, },
canCreateNote() { canCreateNote() {
return this.getNoteableData.current_user.can_create_note; return this.getNoteableData.current_user.can_create_note;
}, },
issueActionButtonTitle() { issueActionButtonTitle() {
if (this.note.length) { const openOrClose = this.isOpen ? 'close' : 'reopen';
const actionText = this.isIssueOpen ? 'close' : 'reopen';
return this.noteType === constants.COMMENT ? if (this.note.length) {
`Comment & ${actionText} issue` : return sprintf(
`Start discussion & ${actionText} issue`; __('%{actionText} & %{openOrClose} %{noteable}'),
{
actionText: this.commentButtonTitle,
openOrClose,
noteable: this.noteableDisplayName,
},
);
} }
return this.isIssueOpen ? 'Close issue' : 'Reopen issue'; return sprintf(
__('%{openOrClose} %{noteable}'),
{
openOrClose: capitalizeFirstCharacter(openOrClose),
noteable: this.noteableDisplayName,
},
);
}, },
actionButtonClassNames() { actionButtonClassNames() {
return { return {
'btn-reopen': !this.isIssueOpen, 'btn-reopen': !this.isOpen,
'btn-close': this.isIssueOpen, 'btn-close': this.isOpen,
'js-note-target-close': this.isIssueOpen, 'js-note-target-close': this.isOpen,
'js-note-target-reopen': !this.isIssueOpen, 'js-note-target-reopen': !this.isOpen,
}; };
}, },
markdownDocsPath() { markdownDocsPath() {
...@@ -138,7 +159,7 @@ ...@@ -138,7 +159,7 @@
flashContainer: this.$el, flashContainer: this.$el,
data: { data: {
note: { note: {
noteable_type: constants.NOTEABLE_TYPE, noteable_type: this.noteableType,
noteable_id: this.getNoteableData.id, noteable_id: this.getNoteableData.id,
note: this.note, note: this.note,
}, },
...@@ -193,19 +214,29 @@ Please check your network connection and try again.`; ...@@ -193,19 +214,29 @@ Please check your network connection and try again.`;
this.isSubmitting = false; this.isSubmitting = false;
}, },
toggleIssueState() { toggleIssueState() {
if (this.isIssueOpen) { if (this.isOpen) {
this.closeIssue() this.closeIssue()
.then(() => this.enableButton()) .then(() => this.enableButton())
.catch(() => { .catch(() => {
this.enableButton(); this.enableButton();
Flash(__('Something went wrong while closing the issue. Please try again later')); Flash(
sprintf(
__('Something went wrong while closing the %{issuable}. Please try again later'),
{ issuable: this.noteableDisplayName },
),
);
}); });
} else { } else {
this.reopenIssue() this.reopenIssue()
.then(() => this.enableButton()) .then(() => this.enableButton())
.catch(() => { .catch(() => {
this.enableButton(); this.enableButton();
Flash(__('Something went wrong while reopening the issue. Please try again later')); Flash(
sprintf(
__('Something went wrong while reopening the %{issuable}. Please try again later'),
{ issuable: this.noteableDisplayName },
),
);
}); });
} }
}, },
...@@ -221,7 +252,6 @@ Please check your network connection and try again.`; ...@@ -221,7 +252,6 @@ Please check your network connection and try again.`;
this.$refs.markdownField.previewMarkdown = false; this.$refs.markdownField.previewMarkdown = false;
} }
// reset autostave
this.autosave.reset(); this.autosave.reset();
}, },
setNoteType(type) { setNoteType(type) {
...@@ -240,10 +270,11 @@ Please check your network connection and try again.`; ...@@ -240,10 +270,11 @@ Please check your network connection and try again.`;
}, },
initAutoSave() { initAutoSave() {
if (this.isLoggedIn) { if (this.isLoggedIn) {
const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType));
this.autosave = new Autosave( this.autosave = new Autosave(
$(this.$refs.textarea), $(this.$refs.textarea),
['Note', 'Issue', this.getNoteableData.id], ['Note', noteableType, this.getNoteableData.id],
'issue',
); );
} }
}, },
...@@ -331,7 +362,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" ...@@ -331,7 +362,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
:disabled="isSubmitButtonDisabled" :disabled="isSubmitButtonDisabled"
class="btn btn-create comment-btn js-comment-button js-comment-submit-button" class="btn btn-create comment-btn js-comment-button js-comment-submit-button"
type="submit"> type="submit">
{{ commentButtonTitle }} {{ __(commentButtonTitle) }}
</button> </button>
<button <button
:disabled="isSubmitButtonDisabled" :disabled="isSubmitButtonDisabled"
...@@ -359,7 +390,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" ...@@ -359,7 +390,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
<div class="description"> <div class="description">
<strong>Comment</strong> <strong>Comment</strong>
<p> <p>
Add a general comment to this issue. Add a general comment to this {{ noteableDisplayName }}.
</p> </p>
</div> </div>
</button> </button>
......
<script>
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
ClipboardButton,
Icon,
},
props: {
diffFile: {
type: Object,
required: true,
},
},
computed: {
titleTag() {
return this.diffFile.discussionPath ? 'a' : 'span';
},
},
};
</script>
<template>
<div class="file-header-content">
<div
v-if="diffFile.submodule"
>
<span>
<icon name="archive" />
<strong
v-html="diffFile.submoduleLink"
class="file-title-name"
></strong>
<clipboard-button
title="Copy file path to clipboard"
:text="diffFile.submoduleLink"
/>
</span>
</div>
<template v-else>
<component
ref="titleWrapper"
:is="titleTag"
:href="diffFile.discussionPath"
>
<span v-html="diffFile.blobIcon"></span>
<span v-if="diffFile.renamedFile">
<strong
class="file-title-name has-tooltip"
:title="diffFile.oldPath"
data-container="body"
>
{{ diffFile.oldPath }}
</strong>
&rarr;
<strong
class="file-title-name has-tooltip"
:title="diffFile.newPath"
data-container="body"
>
{{ diffFile.newPath }}
</strong>
</span>
<strong
v-else
class="file-title-name has-tooltip"
:title="diffFile.oldPath"
data-container="body"
>
{{ diffFile.filePath }}
<span v-if="diffFile.deletedFile">
deleted
</span>
</strong>
</component>
<clipboard-button
title="Copy file path to clipboard"
:text="diffFile.filePath"
/>
<small
v-if="diffFile.modeChanged"
ref="fileMode"
>
{{ diffFile.aMode }}{{ diffFile.bMode }}
</small>
</template>
</div>
</template>
<script>
import syntaxHighlight from '~/syntax_highlight';
import imageDiffHelper from '~/image_diff/helpers/index';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import DiffFileHeader from './diff_file_header.vue';
export default {
components: {
DiffFileHeader,
},
props: {
discussion: {
type: Object,
required: true,
},
},
computed: {
isImageDiff() {
return !this.diffFile.text;
},
diffFileClass() {
const { text } = this.diffFile;
return text ? 'text-file' : 'js-image-file';
},
diffRows() {
return $(this.discussion.truncatedDiffLines);
},
diffFile() {
return convertObjectPropsToCamelCase(this.discussion.diffFile);
},
imageDiffHtml() {
return this.discussion.imageDiffHtml;
},
},
mounted() {
if (this.isImageDiff) {
const canCreateNote = false;
const renderCommentBadge = true;
imageDiffHelper.initImageDiff(this.$refs.fileHolder, canCreateNote, renderCommentBadge);
} else {
const fileHolder = $(this.$refs.fileHolder);
this.$nextTick(() => {
syntaxHighlight(fileHolder);
});
}
},
methods: {
rowTag(html) {
return html.outerHTML ? 'tr' : 'template';
},
},
};
</script>
<template>
<div
ref="fileHolder"
class="diff-file file-holder"
:class="diffFileClass"
>
<div class="js-file-title file-title file-title-flex-parent">
<diff-file-header
:diff-file="diffFile"
/>
</div>
<div
v-if="diffFile.text"
class="diff-content code js-syntax-highlight"
>
<table>
<component
:is="rowTag(html)"
:class="html.className"
v-for="(html, index) in diffRows"
v-html="html.outerHTML"
:key="index"
/>
<tr class="notes_holder">
<td
class="notes_line"
colspan="2"
></td>
<td class="notes_content">
<slot></slot>
</td>
</tr>
</table>
</div>
<div
v-else
>
<div v-html="imageDiffHtml"></div>
<slot></slot>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import resolveSvg from 'icons/_icon_resolve_discussion.svg';
import resolvedSvg from 'icons/_icon_status_success_solid.svg';
import mrIssueSvg from 'icons/_icon_mr_issue.svg';
import nextDiscussionSvg from 'icons/_next_discussion.svg';
import { pluralize } from '../../lib/utils/text_utility';
import { scrollToElement } from '../../lib/utils/common_utils';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
directives: {
tooltip,
},
computed: {
...mapGetters([
'getUserData',
'getNoteableData',
'discussionCount',
'unresolvedDiscussions',
'resolvedDiscussionCount',
]),
isLoggedIn() {
return this.getUserData.id;
},
hasNextButton() {
return this.isLoggedIn && !this.allResolved;
},
countText() {
return pluralize('discussion', this.discussionCount);
},
allResolved() {
return this.resolvedDiscussionCount === this.discussionCount;
},
resolveAllDiscussionsIssuePath() {
return this.getNoteableData.create_issue_to_resolve_discussions_path;
},
firstUnresolvedDiscussionId() {
const item = this.unresolvedDiscussions[0] || {};
return item.id;
},
},
created() {
this.resolveSvg = resolveSvg;
this.resolvedSvg = resolvedSvg;
this.mrIssueSvg = mrIssueSvg;
this.nextDiscussionSvg = nextDiscussionSvg;
},
methods: {
jumpToFirstDiscussion() {
const el = document.querySelector(`[data-discussion-id="${this.firstUnresolvedDiscussionId}"]`);
const activeTab = window.mrTabs.currentAction;
if (activeTab === 'commits' || activeTab === 'pipelines') {
window.mrTabs.activateTab('show');
}
if (el) {
scrollToElement(el);
}
},
},
};
</script>
<template>
<div class="line-resolve-all-container prepend-top-10">
<div>
<div
v-if="discussionCount > 0"
:class="{ 'has-next-btn': hasNextButton }"
class="line-resolve-all">
<span
:class="{ 'is-active': allResolved }"
class="line-resolve-btn is-disabled"
type="button">
<span
v-if="allResolved"
v-html="resolvedSvg"
></span>
<span
v-else
v-html="resolveSvg"
></span>
</span>
<span class=".line-resolve-text">
{{ resolvedDiscussionCount }}/{{ discussionCount }} {{ countText }} resolved
</span>
</div>
<div
v-if="resolveAllDiscussionsIssuePath && !allResolved"
class="btn-group"
role="group">
<a
:href="resolveAllDiscussionsIssuePath"
v-tooltip
title="Resolve all discussions in new issue"
data-container="body"
class="new-issue-for-discussion btn btn-default discussion-create-issue-btn">
<span v-html="mrIssueSvg"></span>
</a>
</div>
<div
v-if="isLoggedIn && !allResolved"
class="btn-group"
role="group">
<button
@click="jumpToFirstDiscussion"
v-tooltip
title="Jump to first unresolved discussion"
data-container="body"
class="btn btn-default discussion-next-btn">
<span v-html="nextDiscussionSvg"></span>
</button>
</div>
</div>
</div>
</template>
...@@ -4,6 +4,8 @@ ...@@ -4,6 +4,8 @@
import emojiSmile from 'icons/_emoji_smile.svg'; import emojiSmile from 'icons/_emoji_smile.svg';
import emojiSmiley from 'icons/_emoji_smiley.svg'; import emojiSmiley from 'icons/_emoji_smiley.svg';
import editSvg from 'icons/_icon_pencil.svg'; import editSvg from 'icons/_icon_pencil.svg';
import resolveDiscussionSvg from 'icons/_icon_resolve_discussion.svg';
import resolvedDiscussionSvg from 'icons/_icon_status_success_solid.svg';
import ellipsisSvg from 'icons/_ellipsis_v.svg'; import ellipsisSvg from 'icons/_ellipsis_v.svg';
import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
...@@ -42,6 +44,26 @@ ...@@ -42,6 +44,26 @@
type: Boolean, type: Boolean,
required: true, required: true,
}, },
resolvable: {
type: Boolean,
required: false,
default: false,
},
isResolved: {
type: Boolean,
required: false,
default: false,
},
isResolving: {
type: Boolean,
required: false,
default: false,
},
resolvedBy: {
type: Object,
required: false,
default: () => ({}),
},
canReportAsAbuse: { canReportAsAbuse: {
type: Boolean, type: Boolean,
required: true, required: true,
...@@ -63,6 +85,15 @@ ...@@ -63,6 +85,15 @@
currentUserId() { currentUserId() {
return this.getUserDataByProp('id'); return this.getUserDataByProp('id');
}, },
resolveButtonTitle() {
let title = 'Mark as resolved';
if (this.resolvedBy) {
title = `Resolved by ${this.resolvedBy.name}`;
}
return title;
},
}, },
created() { created() {
this.emojiSmiling = emojiSmiling; this.emojiSmiling = emojiSmiling;
...@@ -70,6 +101,8 @@ ...@@ -70,6 +101,8 @@
this.emojiSmiley = emojiSmiley; this.emojiSmiley = emojiSmiley;
this.editSvg = editSvg; this.editSvg = editSvg;
this.ellipsisSvg = ellipsisSvg; this.ellipsisSvg = ellipsisSvg;
this.resolveDiscussionSvg = resolveDiscussionSvg;
this.resolvedDiscussionSvg = resolvedDiscussionSvg;
}, },
methods: { methods: {
onEdit() { onEdit() {
...@@ -78,6 +111,9 @@ ...@@ -78,6 +111,9 @@
onDelete() { onDelete() {
this.$emit('handleDelete'); this.$emit('handleDelete');
}, },
onResolve() {
this.$emit('handleResolve');
},
}, },
}; };
</script> </script>
...@@ -89,6 +125,31 @@ ...@@ -89,6 +125,31 @@
class="note-role user-access-role"> class="note-role user-access-role">
{{ accessLevel }} {{ accessLevel }}
</span> </span>
<div
v-if="resolvable"
class="note-actions-item">
<button
v-tooltip
@click="onResolve"
:class="{ 'is-disabled': !resolvable, 'is-active': isResolved }"
:title="resolveButtonTitle"
:aria-label="resolveButtonTitle"
type="button"
class="line-resolve-btn note-action-button">
<template v-if="!isResolving">
<div
v-if="isResolved"
v-html="resolvedDiscussionSvg"></div>
<div
v-else
v-html="resolveDiscussionSvg"></div>
</template>
<loading-icon
v-else
:inline="true"
/>
</button>
</div>
<div <div
v-if="canAddAwardEmoji" v-if="canAddAwardEmoji"
class="note-actions-item"> class="note-actions-item">
......
...@@ -41,7 +41,7 @@ ...@@ -41,7 +41,7 @@
this.initTaskList(); this.initTaskList();
if (this.isEditing) { if (this.isEditing) {
this.initAutoSave(); this.initAutoSave(this.note.noteable_type);
} }
}, },
updated() { updated() {
...@@ -50,7 +50,7 @@ ...@@ -50,7 +50,7 @@
if (this.isEditing) { if (this.isEditing) {
if (!this.autosave) { if (!this.autosave) {
this.initAutoSave(); this.initAutoSave(this.note.noteable_type);
} else { } else {
this.setAutoSave(); this.setAutoSave();
} }
......
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
import markdownField from '../../vue_shared/components/markdown/field.vue'; import markdownField from '../../vue_shared/components/markdown/field.vue';
import issuableStateMixin from '../mixins/issuable_state'; import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
export default { export default {
name: 'IssueNoteForm', name: 'IssueNoteForm',
...@@ -13,6 +14,7 @@ ...@@ -13,6 +14,7 @@
}, },
mixins: [ mixins: [
issuableStateMixin, issuableStateMixin,
resolvable,
], ],
props: { props: {
noteBody: { noteBody: {
...@@ -30,7 +32,7 @@ ...@@ -30,7 +32,7 @@
required: false, required: false,
default: 'Save comment', default: 'Save comment',
}, },
discussion: { note: {
type: Object, type: Object,
required: false, required: false,
default: () => ({}), default: () => ({}),
...@@ -42,9 +44,11 @@ ...@@ -42,9 +44,11 @@
}, },
data() { data() {
return { return {
note: this.noteBody, updatedNoteBody: this.noteBody,
conflictWhileEditing: false, conflictWhileEditing: false,
isSubmitting: false, isSubmitting: false,
isResolving: false,
resolveAsThread: true,
}; };
}, },
computed: { computed: {
...@@ -71,13 +75,13 @@ ...@@ -71,13 +75,13 @@
return this.getUserDataByProp('id'); return this.getUserDataByProp('id');
}, },
isDisabled() { isDisabled() {
return !this.note.length || this.isSubmitting; return !this.updatedNoteBody.length || this.isSubmitting;
}, },
}, },
watch: { watch: {
noteBody() { noteBody() {
if (this.note === this.noteBody) { if (this.updatedNoteBody === this.noteBody) {
this.note = this.noteBody; this.updatedNoteBody = this.noteBody;
} else { } else {
this.conflictWhileEditing = true; this.conflictWhileEditing = true;
} }
...@@ -87,16 +91,24 @@ ...@@ -87,16 +91,24 @@
this.$refs.textarea.focus(); this.$refs.textarea.focus();
}, },
methods: { methods: {
handleUpdate() { ...mapActions([
'toggleResolveNote',
]),
handleUpdate(shouldResolve) {
const beforeSubmitDiscussionState = this.discussionResolved;
this.isSubmitting = true; this.isSubmitting = true;
this.$emit('handleFormUpdate', this.note, this.$refs.editNoteForm, () => { this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => {
this.isSubmitting = false; this.isSubmitting = false;
if (shouldResolve) {
this.resolveHandler(beforeSubmitDiscussionState);
}
}); });
}, },
editMyLastNote() { editMyLastNote() {
if (this.note === '') { if (this.updatedNoteBody === '') {
const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion); const lastNoteInDiscussion = this.getDiscussionLastNote(this.updatedNoteBody);
if (lastNoteInDiscussion) { if (lastNoteInDiscussion) {
eventHub.$emit('enterEditMode', { eventHub.$emit('enterEditMode', {
...@@ -107,7 +119,7 @@ ...@@ -107,7 +119,7 @@
}, },
cancelHandler(shouldConfirm = false) { cancelHandler(shouldConfirm = false) {
// Sends information about confirm message and if the textarea has changed // Sends information about confirm message and if the textarea has changed
this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.note); this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.updatedNoteBody);
}, },
}, },
}; };
...@@ -150,7 +162,7 @@ ...@@ -150,7 +162,7 @@
js-autosize markdown-area js-vue-issue-note-form js-vue-textarea" js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
:data-supports-quick-actions="!isEditing" :data-supports-quick-actions="!isEditing"
aria-label="Description" aria-label="Description"
v-model="note" v-model="updatedNoteBody"
ref="textarea" ref="textarea"
slot="textarea" slot="textarea"
placeholder="Write a comment or drag your files here..." placeholder="Write a comment or drag your files here..."
...@@ -168,6 +180,13 @@ js-autosize markdown-area js-vue-issue-note-form js-vue-textarea" ...@@ -168,6 +180,13 @@ js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
class="js-vue-issue-save btn btn-save"> class="js-vue-issue-save btn btn-save">
{{ saveButtonTitle }} {{ saveButtonTitle }}
</button> </button>
<button
v-if="note.resolvable"
@click.prevent="handleUpdate(true)"
class="btn btn-nr btn-default append-right-10 js-comment-resolve-button"
>
{{ resolveButtonTitle }}
</button>
<button <button
@click="cancelHandler()" @click="cancelHandler()"
class="btn btn-cancel note-edit-cancel" class="btn btn-cancel note-edit-cancel"
......
...@@ -34,15 +34,15 @@ ...@@ -34,15 +34,15 @@
required: false, required: false,
default: false, default: false,
}, },
expanded: {
type: Boolean,
required: false,
default: true,
}, },
data() {
return {
isExpanded: true,
};
}, },
computed: { computed: {
toggleChevronClass() { toggleChevronClass() {
return this.isExpanded ? 'fa-chevron-up' : 'fa-chevron-down'; return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down';
}, },
noteTimestampLink() { noteTimestampLink() {
return `#note_${this.noteId}`; return `#note_${this.noteId}`;
...@@ -53,7 +53,6 @@ ...@@ -53,7 +53,6 @@
'setTargetNoteHash', 'setTargetNoteHash',
]), ]),
handleToggle() { handleToggle() {
this.isExpanded = !this.isExpanded;
this.$emit('toggleHandler'); this.$emit('toggleHandler');
}, },
updateTargetNoteHash() { updateTargetNoteHash() {
......
<script> <script>
import { mapActions, mapGetters } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import resolveDiscussionsSvg from 'icons/_icon_mr_issue.svg';
import nextDiscussionsSvg from 'icons/_next_discussion.svg';
import Flash from '../../flash'; import Flash from '../../flash';
import { SYSTEM_NOTE } from '../constants'; import { SYSTEM_NOTE } from '../constants';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
...@@ -8,13 +10,19 @@ ...@@ -8,13 +10,19 @@
import noteSignedOutWidget from './note_signed_out_widget.vue'; import noteSignedOutWidget from './note_signed_out_widget.vue';
import noteEditedText from './note_edited_text.vue'; import noteEditedText from './note_edited_text.vue';
import noteForm from './note_form.vue'; import noteForm from './note_form.vue';
import diffWithNote from './diff_with_note.vue';
import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
import autosave from '../mixins/autosave'; import autosave from '../mixins/autosave';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
import tooltip from '../../vue_shared/directives/tooltip';
import { scrollToElement } from '../../lib/utils/common_utils';
export default { export default {
components: { components: {
noteableNote, noteableNote,
diffWithNote,
userAvatarLink, userAvatarLink,
noteHeader, noteHeader,
noteSignedOutWidget, noteSignedOutWidget,
...@@ -23,8 +31,13 @@ ...@@ -23,8 +31,13 @@
placeholderNote, placeholderNote,
placeholderSystemNote, placeholderSystemNote,
}, },
directives: {
tooltip,
},
mixins: [ mixins: [
autosave, autosave,
noteable,
resolvable,
], ],
props: { props: {
note: { note: {
...@@ -35,14 +48,25 @@ ...@@ -35,14 +48,25 @@
data() { data() {
return { return {
isReplying: false, isReplying: false,
isResolving: false,
resolveAsThread: true,
}; };
}, },
computed: { computed: {
...mapGetters([ ...mapGetters([
'getNoteableData', 'getNoteableData',
'discussionCount',
'resolvedDiscussionCount',
'unresolvedDiscussions',
]), ]),
discussion() { discussion() {
return this.note.notes[0]; return {
...this.note.notes[0],
truncatedDiffLines: this.note.truncated_diff_lines,
diffFile: this.note.diff_file,
diffDiscussion: this.note.diff_discussion,
imageDiffHtml: this.note.image_diff_html,
};
}, },
author() { author() {
return this.discussion.author; return this.discussion.author;
...@@ -71,26 +95,40 @@ ...@@ -71,26 +95,40 @@
return null; return null;
}, },
hasUnresolvedDiscussion() {
return this.unresolvedDiscussions.length > 0;
},
wrapperComponent() {
return (this.discussion.diffDiscussion && this.discussion.diffFile) ? diffWithNote : 'div';
},
wrapperClass() {
return this.isDiffDiscussion ? '' : 'panel panel-default';
},
}, },
mounted() { mounted() {
if (this.isReplying) { if (this.isReplying) {
this.initAutoSave(); this.initAutoSave(this.discussion.noteable_type);
} }
}, },
updated() { updated() {
if (this.isReplying) { if (this.isReplying) {
if (!this.autosave) { if (!this.autosave) {
this.initAutoSave(); this.initAutoSave(this.discussion.noteable_type);
} else { } else {
this.setAutoSave(); this.setAutoSave();
} }
} }
}, },
created() {
this.resolveDiscussionsSvg = resolveDiscussionsSvg;
this.nextDiscussionsSvg = nextDiscussionsSvg;
},
methods: { methods: {
...mapActions([ ...mapActions([
'saveNote', 'saveNote',
'toggleDiscussion', 'toggleDiscussion',
'removePlaceholderNotes', 'removePlaceholderNotes',
'toggleResolveNote',
]), ]),
componentName(note) { componentName(note) {
if (note.isPlaceholderNote) { if (note.isPlaceholderNote) {
...@@ -103,7 +141,7 @@ ...@@ -103,7 +141,7 @@
return noteableNote; return noteableNote;
}, },
componentData(note) { componentData(note) {
return note.isPlaceholderNote ? note.notes[0] : note; return note.isPlaceholderNote ? this.note.notes[0] : note;
}, },
toggleDiscussionHandler() { toggleDiscussionHandler() {
this.toggleDiscussion({ discussionId: this.note.id }); this.toggleDiscussion({ discussionId: this.note.id });
...@@ -128,7 +166,7 @@ ...@@ -128,7 +166,7 @@
flashContainer: this.$el, flashContainer: this.$el,
data: { data: {
in_reply_to_discussion_id: this.note.reply_id, in_reply_to_discussion_id: this.note.reply_id,
target_type: 'issue', target_type: this.noteableType,
target_id: this.discussion.noteable_id, target_id: this.discussion.noteable_id,
note: { note: noteText }, note: { note: noteText },
}, },
...@@ -152,12 +190,27 @@ Please check your network connection and try again.`; ...@@ -152,12 +190,27 @@ Please check your network connection and try again.`;
}); });
}); });
}, },
jumpToDiscussion() {
const unresolvedIds = this.unresolvedDiscussions.map(d => d.id);
const index = unresolvedIds.indexOf(this.note.id);
if (index >= 0 && index !== unresolvedIds.length) {
const nextId = unresolvedIds[index + 1];
const el = document.querySelector(`[data-discussion-id="${nextId}"]`);
if (el) {
scrollToElement(el);
}
}
},
}, },
}; };
</script> </script>
<template> <template>
<li class="note note-discussion timeline-entry"> <li
:data-discussion-id="note.id"
class="note note-discussion timeline-entry">
<div class="timeline-entry-inner"> <div class="timeline-entry-inner">
<div class="timeline-icon"> <div class="timeline-icon">
<user-avatar-link <user-avatar-link
...@@ -175,6 +228,7 @@ Please check your network connection and try again.`; ...@@ -175,6 +228,7 @@ Please check your network connection and try again.`;
:created-at="discussion.created_at" :created-at="discussion.created_at"
:note-id="discussion.id" :note-id="discussion.id"
:include-toggle="true" :include-toggle="true"
:expanded="note.expanded"
@toggleHandler="toggleDiscussionHandler" @toggleHandler="toggleDiscussionHandler"
action-text="started a discussion" action-text="started a discussion"
class="discussion" class="discussion"
...@@ -187,11 +241,14 @@ Please check your network connection and try again.`; ...@@ -187,11 +241,14 @@ Please check your network connection and try again.`;
class-name="discussion-headline-light js-discussion-headline" class-name="discussion-headline-light js-discussion-headline"
/> />
</div> </div>
</div>
<div <div
v-if="note.expanded" v-if="note.expanded"
class="discussion-body"> class="discussion-body">
<div class="panel panel-default"> <component
:is="wrapperComponent"
:discussion="discussion"
:class="wrapperClass"
>
<div class="discussion-notes"> <div class="discussion-notes">
<ul class="notes"> <ul class="notes">
<component <component
...@@ -204,26 +261,83 @@ Please check your network connection and try again.`; ...@@ -204,26 +261,83 @@ Please check your network connection and try again.`;
<div <div
:class="{ 'is-replying': isReplying }" :class="{ 'is-replying': isReplying }"
class="discussion-reply-holder"> class="discussion-reply-holder">
<template v-if="!isReplying && canReply">
<div
class="btn-group-justified discussion-with-resolve-btn"
role="group">
<div
class="btn-group"
role="group">
<button <button
v-if="canReply && !isReplying"
@click="showReplyForm" @click="showReplyForm"
type="button" type="button"
class="js-vue-discussion-reply btn btn-text-field" class="js-vue-discussion-reply btn btn-text-field"
title="Add a reply"> title="Add a reply">Reply...</button>
Reply... </div>
<div
v-if="note.resolvable"
class="btn-group"
role="group">
<button
@click="resolveHandler()"
type="button"
class="btn btn-default"
>
<i
v-if="isResolving"
aria-hidden="true"
class="fa fa-spinner fa-spin"
></i>
{{ resolveButtonTitle }}
</button> </button>
</div>
<div
class="btn-group discussion-actions"
role="group">
<div
v-if="note.resolvable && !discussionResolved"
class="btn-group"
role="group">
<a
:href="note.resolve_with_issue_path"
v-tooltip
class="new-issue-for-discussion btn
btn-default discussion-create-issue-btn"
title="Resolve this discussion in a new issue"
data-container="body"
>
<span v-html="resolveDiscussionsSvg"></span>
</a>
</div>
<div
v-if="hasUnresolvedDiscussion"
class="btn-group"
role="group">
<button
@click="jumpToDiscussion"
v-tooltip
class="btn btn-default discussion-next-btn"
title="Jump to next unresolved discussion"
data-container="body"
>
<span v-html="nextDiscussionsSvg"></span>
</button>
</div>
</div>
</div>
</template>
<note-form <note-form
v-if="isReplying" v-if="isReplying"
save-button-title="Comment" save-button-title="Comment"
:discussion="note" :note="note"
:is-editing="false" :is-editing="false"
@handleFormUpdate="saveReply" @handleFormUpdate="saveReply"
@cancelFormEdition="cancelReplyForm" @cancelFormEdition="cancelReplyForm"
ref="noteForm" ref="noteForm" />
/>
<note-signed-out-widget v-if="!canReply" /> <note-signed-out-widget v-if="!canReply" />
</div> </div>
</div> </div>
</component>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -7,6 +7,8 @@ ...@@ -7,6 +7,8 @@
import noteActions from './note_actions.vue'; import noteActions from './note_actions.vue';
import noteBody from './note_body.vue'; import noteBody from './note_body.vue';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
export default { export default {
components: { components: {
...@@ -15,6 +17,10 @@ ...@@ -15,6 +17,10 @@
noteActions, noteActions,
noteBody, noteBody,
}, },
mixins: [
noteable,
resolvable,
],
props: { props: {
note: { note: {
type: Object, type: Object,
...@@ -26,6 +32,7 @@ ...@@ -26,6 +32,7 @@
isEditing: false, isEditing: false,
isDeleting: false, isDeleting: false,
isRequesting: false, isRequesting: false,
isResolving: false,
}; };
}, },
computed: { computed: {
...@@ -65,6 +72,7 @@ ...@@ -65,6 +72,7 @@
...mapActions([ ...mapActions([
'deleteNote', 'deleteNote',
'updateNote', 'updateNote',
'toggleResolveNote',
'scrollToNoteIfNeeded', 'scrollToNoteIfNeeded',
]), ]),
editHandler() { editHandler() {
...@@ -89,7 +97,7 @@ ...@@ -89,7 +97,7 @@
const data = { const data = {
endpoint: this.note.path, endpoint: this.note.path,
note: { note: {
target_type: 'issue', target_type: this.noteableType,
target_id: this.note.noteable_id, target_id: this.note.noteable_id,
note: { note: noteText }, note: { note: noteText },
}, },
...@@ -134,7 +142,7 @@ ...@@ -134,7 +142,7 @@
// we need to do this to prevent noteForm inconsistent content warning // we need to do this to prevent noteForm inconsistent content warning
// this is something we intentionally do so we need to recover the content // this is something we intentionally do so we need to recover the content
this.note.note = noteText; this.note.note = noteText;
this.$refs.noteBody.$refs.noteForm.note = noteText; this.$refs.noteBody.$refs.noteForm.note.note = noteText;
}, },
}, },
}; };
...@@ -171,8 +179,13 @@ ...@@ -171,8 +179,13 @@
:can-delete="note.current_user.can_edit" :can-delete="note.current_user.can_edit"
:can-report-as-abuse="canReportAsAbuse" :can-report-as-abuse="canReportAsAbuse"
:report-abuse-path="note.report_abuse_path" :report-abuse-path="note.report_abuse_path"
:resolvable="note.resolvable"
:is-resolved="note.resolved"
:is-resolving="isResolving"
:resolved-by="note.resolved_by"
@handleEdit="editHandler" @handleEdit="editHandler"
@handleDelete="deleteHandler" @handleDelete="deleteHandler"
@handleResolve="resolveHandler"
/> />
</div> </div>
<note-body <note-body
......
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue';
export default { export default {
name: 'NotesApp', name: 'NotesApp',
...@@ -48,7 +49,24 @@ ...@@ -48,7 +49,24 @@
...mapGetters([ ...mapGetters([
'notes', 'notes',
'getNotesDataByProp', 'getNotesDataByProp',
'discussionCount',
]), ]),
noteableType() {
// FIXME -- @fatihacet Get this from JSON data.
const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE } = constants;
return this.noteableData.merge_params ? MERGE_REQUEST_NOTEABLE_TYPE : ISSUE_NOTEABLE_TYPE;
},
allNotes() {
if (this.isLoading) {
const totalNotes = parseInt(this.notesData.totalNotes, 10) || 0;
return new Array(totalNotes).fill({
isSkeletonNote: true,
});
}
return this.notes;
},
}, },
created() { created() {
this.setNotesData(this.notesData); this.setNotesData(this.notesData);
...@@ -67,6 +85,10 @@ ...@@ -67,6 +85,10 @@
this.actionToggleAward({ awardName, noteId }); this.actionToggleAward({ awardName, noteId });
}); });
} }
document.addEventListener('refreshVueNotes', this.fetchNotes);
},
beforeDestroy() {
document.removeEventListener('refreshVueNotes', this.fetchNotes);
}, },
methods: { methods: {
...mapActions({ ...mapActions({
...@@ -81,6 +103,9 @@ ...@@ -81,6 +103,9 @@
setTargetNoteHash: 'setTargetNoteHash', setTargetNoteHash: 'setTargetNoteHash',
}), }),
getComponentName(note) { getComponentName(note) {
if (note.isSkeletonNote) {
return skeletonLoadingContainer;
}
if (note.isPlaceholderNote) { if (note.isPlaceholderNote) {
if (note.placeholderType === constants.SYSTEM_NOTE) { if (note.placeholderType === constants.SYSTEM_NOTE) {
return placeholderSystemNote; return placeholderSystemNote;
...@@ -109,9 +134,14 @@ ...@@ -109,9 +134,14 @@
}); });
}, },
initPolling() { initPolling() {
if (this.isPollingInitialized) {
return;
}
this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt')); this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt'));
this.poll(); this.poll();
this.isPollingInitialized = true;
}, },
checkLocationHash() { checkLocationHash() {
const hash = getLocationHash(); const hash = getLocationHash();
...@@ -128,25 +158,20 @@ ...@@ -128,25 +158,20 @@
<template> <template>
<div id="notes"> <div id="notes">
<div
v-if="isLoading"
class="js-loading loading">
<loading-icon />
</div>
<ul <ul
v-if="!isLoading"
id="notes-list" id="notes-list"
class="notes main-notes-list timeline"> class="notes main-notes-list timeline">
<component <component
v-for="note in notes" v-for="note in allNotes"
:is="getComponentName(note)" :is="getComponentName(note)"
:note="getComponentData(note)" :note="getComponentData(note)"
:key="note.id" :key="note.id"
/> />
</ul> </ul>
<comment-form /> <comment-form
:noteable-type="noteableType"
/>
</div> </div>
</template> </template>
export const DISCUSSION_NOTE = 'DiscussionNote'; export const DISCUSSION_NOTE = 'DiscussionNote';
export const DIFF_NOTE = 'DiffNote';
export const DISCUSSION = 'discussion'; export const DISCUSSION = 'discussion';
export const NOTE = 'note'; export const NOTE = 'note';
export const SYSTEM_NOTE = 'systemNote'; export const SYSTEM_NOTE = 'systemNote';
...@@ -8,4 +9,7 @@ export const REOPENED = 'reopened'; ...@@ -8,4 +9,7 @@ export const REOPENED = 'reopened';
export const CLOSED = 'closed'; export const CLOSED = 'closed';
export const EMOJI_THUMBSUP = 'thumbsup'; export const EMOJI_THUMBSUP = 'thumbsup';
export const EMOJI_THUMBSDOWN = 'thumbsdown'; export const EMOJI_THUMBSDOWN = 'thumbsdown';
export const NOTEABLE_TYPE = 'Issue'; export const ISSUE_NOTEABLE_TYPE = 'issue';
export const MERGE_REQUEST_NOTEABLE_TYPE = 'merge_request';
export const UNRESOLVE_NOTE_METHOD_NAME = 'delete';
export const RESOLVE_NOTE_METHOD_NAME = 'post';
...@@ -20,17 +20,7 @@ document.addEventListener('DOMContentLoaded', () => new Vue({ ...@@ -20,17 +20,7 @@ document.addEventListener('DOMContentLoaded', () => new Vue({
return { return {
noteableData: JSON.parse(notesDataset.noteableData), noteableData: JSON.parse(notesDataset.noteableData),
currentUserData, currentUserData,
notesData: { notesData: JSON.parse(notesDataset.notesData),
lastFetchedAt: notesDataset.lastFetchedAt,
discussionsPath: notesDataset.discussionsPath,
newSessionPath: notesDataset.newSessionPath,
registerPath: notesDataset.registerPath,
notesPath: notesDataset.notesPath,
markdownDocsPath: notesDataset.markdownDocsPath,
quickActionsDocsPath: notesDataset.quickActionsDocsPath,
closeIssuePath: notesDataset.closeIssuePath,
reopenIssuePath: notesDataset.reopenIssuePath,
},
}; };
}, },
render(createElement) { render(createElement) {
......
import Autosave from '../../autosave'; import Autosave from '../../autosave';
import { capitalizeFirstCharacter } from '../../lib/utils/text_utility';
export default { export default {
methods: { methods: {
initAutoSave() { initAutoSave(noteableType) {
this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', 'Issue', this.note.id], 'issue'); this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', capitalizeFirstCharacter(noteableType), this.note.id]);
}, },
resetAutoSave() { resetAutoSave() {
this.autosave.reset(); this.autosave.reset();
......
import * as constants from '../constants';
export default {
props: {
note: {
type: Object,
required: true,
},
},
computed: {
noteableType() {
switch (this.note.noteable_type) {
case 'MergeRequest':
return constants.MERGE_REQUEST_NOTEABLE_TYPE;
case 'Issue':
return constants.ISSUE_NOTEABLE_TYPE;
default:
return '';
}
},
},
};
import Flash from '~/flash';
import { __ } from '~/locale';
export default {
props: {
note: {
type: Object,
required: true,
},
},
computed: {
discussionResolved() {
const { notes, resolved } = this.note;
if (notes) { // Decide resolved state using store. Only valid for discussions.
return notes.every(note => note.resolved && !note.system);
}
return resolved;
},
resolveButtonTitle() {
if (this.updatedNoteBody) {
if (this.discussionResolved) {
return __('Comment and unresolve discussion');
}
return __('Comment and resolve discussion');
}
return this.discussionResolved ? __('Unresolve discussion') : __('Resolve discussion');
},
},
methods: {
resolveHandler(resolvedState = false) {
this.isResolving = true;
const endpoint = this.note.resolve_path || `${this.note.path}/resolve`;
const isResolved = this.discussionResolved || resolvedState;
const discussion = this.resolveAsThread;
this.toggleResolveNote({ endpoint, isResolved, discussion })
.then(() => {
this.isResolving = false;
})
.catch(() => {
this.isResolving = false;
const msg = __('Something went wrong while resolving this discussion. Please try again.');
Flash(msg, 'alert', this.$el);
});
},
},
};
import Vue from 'vue'; import Vue from 'vue';
import VueResource from 'vue-resource'; import VueResource from 'vue-resource';
import * as constants from '../constants';
Vue.use(VueResource); Vue.use(VueResource);
...@@ -19,6 +20,12 @@ export default { ...@@ -19,6 +20,12 @@ export default {
createNewNote(endpoint, data) { createNewNote(endpoint, data) {
return Vue.http.post(endpoint, data, { emulateJSON: true }); return Vue.http.post(endpoint, data, { emulateJSON: true });
}, },
toggleResolveNote(endpoint, isResolved) {
const { RESOLVE_NOTE_METHOD_NAME, UNRESOLVE_NOTE_METHOD_NAME } = constants;
const method = isResolved ? UNRESOLVE_NOTE_METHOD_NAME : RESOLVE_NOTE_METHOD_NAME;
return Vue.http[method](endpoint);
},
poll(data = {}) { poll(data = {}) {
const { endpoint, lastFetchedAt } = data; const { endpoint, lastFetchedAt } = data;
const options = { const options = {
......
...@@ -61,8 +61,17 @@ export const createNewNote = ({ commit }, { endpoint, data }) => service ...@@ -61,8 +61,17 @@ export const createNewNote = ({ commit }, { endpoint, data }) => service
export const removePlaceholderNotes = ({ commit }) => export const removePlaceholderNotes = ({ commit }) =>
commit(types.REMOVE_PLACEHOLDER_NOTES); commit(types.REMOVE_PLACEHOLDER_NOTES);
export const toggleResolveNote = ({ commit }, { endpoint, isResolved, discussion }) => service
.toggleResolveNote(endpoint, isResolved)
.then(res => res.json())
.then((res) => {
const mutationType = discussion ? types.UPDATE_DISCUSSION : types.UPDATE_NOTE;
commit(mutationType, res);
});
export const closeIssue = ({ commit, dispatch, state }) => service export const closeIssue = ({ commit, dispatch, state }) => service
.toggleIssueState(state.notesData.closeIssuePath) .toggleIssueState(state.notesData.closePath)
.then(res => res.json()) .then(res => res.json())
.then((data) => { .then((data) => {
commit(types.CLOSE_ISSUE); commit(types.CLOSE_ISSUE);
...@@ -70,7 +79,7 @@ export const closeIssue = ({ commit, dispatch, state }) => service ...@@ -70,7 +79,7 @@ export const closeIssue = ({ commit, dispatch, state }) => service
}); });
export const reopenIssue = ({ commit, dispatch, state }) => service export const reopenIssue = ({ commit, dispatch, state }) => service
.toggleIssueState(state.notesData.reopenIssuePath) .toggleIssueState(state.notesData.reopenPath)
.then(res => res.json()) .then(res => res.json())
.then((data) => { .then((data) => {
commit(types.REOPEN_ISSUE); commit(types.REOPEN_ISSUE);
...@@ -80,7 +89,7 @@ export const reopenIssue = ({ commit, dispatch, state }) => service ...@@ -80,7 +89,7 @@ export const reopenIssue = ({ commit, dispatch, state }) => service
export const emitStateChangedEvent = ({ commit, getters }, data) => { export const emitStateChangedEvent = ({ commit, getters }, data) => {
const event = new CustomEvent('issuable_vue_app:change', { detail: { const event = new CustomEvent('issuable_vue_app:change', { detail: {
data, data,
isClosed: getters.issueState === constants.CLOSED, isClosed: getters.openState === constants.CLOSED,
} }); } });
document.dispatchEvent(event); document.dispatchEvent(event);
...@@ -174,7 +183,7 @@ const pollSuccessCallBack = (resp, commit, state, getters) => { ...@@ -174,7 +183,7 @@ const pollSuccessCallBack = (resp, commit, state, getters) => {
resp.notes.forEach((note) => { resp.notes.forEach((note) => {
if (notesById[note.id]) { if (notesById[note.id]) {
commit(types.UPDATE_NOTE, note); commit(types.UPDATE_NOTE, note);
} else if (note.type === constants.DISCUSSION_NOTE) { } else if (note.type === constants.DISCUSSION_NOTE || note.type === constants.DIFF_NOTE) {
const discussion = utils.findNoteObjectById(state.notes, note.discussion_id); const discussion = utils.findNoteObjectById(state.notes, note.discussion_id);
if (discussion) { if (discussion) {
......
...@@ -8,7 +8,7 @@ export const getNotesDataByProp = state => prop => state.notesData[prop]; ...@@ -8,7 +8,7 @@ export const getNotesDataByProp = state => prop => state.notesData[prop];
export const getNoteableData = state => state.noteableData; export const getNoteableData = state => state.noteableData;
export const getNoteableDataByProp = state => prop => state.noteableData[prop]; export const getNoteableDataByProp = state => prop => state.noteableData[prop];
export const issueState = state => state.noteableData.state; export const openState = state => state.noteableData.state;
export const getUserData = state => state.userData || {}; export const getUserData = state => state.userData || {};
export const getUserDataByProp = state => prop => state.userData && state.userData[prop]; export const getUserDataByProp = state => prop => state.userData && state.userData[prop];
...@@ -30,3 +30,37 @@ export const getCurrentUserLastNote = state => _.flatten( ...@@ -30,3 +30,37 @@ export const getCurrentUserLastNote = state => _.flatten(
export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes) export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes)
.find(el => isLastNote(el, state)); .find(el => isLastNote(el, state));
export const discussionCount = (state) => {
const discussions = state.notes.filter(n => !n.individual_note);
return discussions.length;
};
export const unresolvedDiscussions = (state, getters) => {
const resolvedMap = getters.resolvedDiscussionsById;
return state.notes.filter(n => !n.individual_note && !resolvedMap[n.id]);
};
export const resolvedDiscussionsById = (state) => {
const map = {};
state.notes.forEach((n) => {
if (n.notes) {
const resolved = n.notes.every(note => note.resolved && !note.system);
if (resolved) {
map[n.id] = n;
}
}
});
return map;
};
export const resolvedDiscussionCount = (state, getters) => {
const resolvedMap = getters.resolvedDiscussionsById;
return Object.keys(resolvedMap).length;
};
...@@ -12,6 +12,7 @@ export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE'; ...@@ -12,6 +12,7 @@ export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE';
export const TOGGLE_AWARD = 'TOGGLE_AWARD'; export const TOGGLE_AWARD = 'TOGGLE_AWARD';
export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION'; export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION';
export const UPDATE_NOTE = 'UPDATE_NOTE'; export const UPDATE_NOTE = 'UPDATE_NOTE';
export const UPDATE_DISCUSSION = 'UPDATE_DISCUSSION';
// Issue // Issue
export const CLOSE_ISSUE = 'CLOSE_ISSUE'; export const CLOSE_ISSUE = 'CLOSE_ISSUE';
......
import * as utils from './utils'; import * as utils from './utils';
import * as types from './mutation_types'; import * as types from './mutation_types';
import * as constants from '../constants'; import * as constants from '../constants';
import { isInMRPage } from '../../lib/utils/common_utils';
export default { export default {
[types.ADD_NEW_NOTE](state, note) { [types.ADD_NEW_NOTE](state, note) {
const { discussion_id, type } = note; const { discussion_id, type } = note;
const [exists] = state.notes.filter(n => n.id === note.discussion_id); const [exists] = state.notes.filter(n => n.id === note.discussion_id);
const isDiscussion = (type === constants.DISCUSSION_NOTE);
if (!exists) { if (!exists) {
const noteData = { const noteData = {
expanded: true, expanded: true,
id: discussion_id, id: discussion_id,
individual_note: !(type === constants.DISCUSSION_NOTE), individual_note: !isDiscussion,
notes: [note], notes: [note],
reply_id: discussion_id, reply_id: discussion_id,
}; };
if (isDiscussion && isInMRPage()) {
noteData.resolvable = note.resolvable;
noteData.resolved = false;
noteData.resolve_path = note.resolve_path;
noteData.resolve_with_issue_path = note.resolve_with_issue_path;
}
state.notes.push(noteData); state.notes.push(noteData);
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
} }
}, },
...@@ -25,6 +35,7 @@ export default { ...@@ -25,6 +35,7 @@ export default {
if (noteObj) { if (noteObj) {
noteObj.notes.push(note); noteObj.notes.push(note);
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
} }
}, },
...@@ -41,6 +52,8 @@ export default { ...@@ -41,6 +52,8 @@ export default {
state.notes.splice(state.notes.indexOf(noteObj), 1); state.notes.splice(state.notes.indexOf(noteObj), 1);
} }
} }
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
}, },
[types.REMOVE_PLACEHOLDER_NOTES](state) { [types.REMOVE_PLACEHOLDER_NOTES](state) {
...@@ -77,15 +90,19 @@ export default { ...@@ -77,15 +90,19 @@ export default {
const notes = []; const notes = [];
notesData.forEach((note) => { notesData.forEach((note) => {
const nn = Object.assign({}, note);
// To support legacy notes, should be very rare case. // To support legacy notes, should be very rare case.
if (note.individual_note && note.notes.length > 1) { if (note.individual_note && note.notes.length > 1) {
note.notes.forEach((n) => { note.notes.forEach((n) => {
const nn = Object.assign({}, note);
nn.notes = [n]; // override notes array to only have one item to mimick individual_note nn.notes = [n]; // override notes array to only have one item to mimick individual_note
notes.push(nn); notes.push(nn);
}); });
} else { } else {
notes.push(note); const oldNote = utils.findNoteObjectById(state.notes, note.id);
nn.expanded = oldNote ? oldNote.expanded : note.expanded;
notes.push(nn);
} }
}); });
...@@ -134,6 +151,8 @@ export default { ...@@ -134,6 +151,8 @@ export default {
user: { id, name, username }, user: { id, name, username },
}); });
} }
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
}, },
[types.TOGGLE_DISCUSSION](state, { discussionId }) { [types.TOGGLE_DISCUSSION](state, { discussionId }) {
...@@ -151,6 +170,24 @@ export default { ...@@ -151,6 +170,24 @@ export default {
const comment = utils.findNoteObjectById(noteObj.notes, note.id); const comment = utils.findNoteObjectById(noteObj.notes, note.id);
noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note); noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note);
} }
// document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
},
[types.UPDATE_DISCUSSION](state, noteData) {
const note = noteData;
let index = 0;
state.notes.forEach((n, i) => {
if (n.id === note.id) {
index = i;
}
});
note.expanded = true; // override expand flag to prevent collapse
state.notes.splice(index, 1, note);
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
}, },
[types.CLOSE_ISSUE](state) { [types.CLOSE_ISSUE](state) {
......
...@@ -28,4 +28,3 @@ export const getQuickActionText = (note) => { ...@@ -28,4 +28,3 @@ export const getQuickActionText = (note) => {
export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note); export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note);
export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim(); export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim();
...@@ -2,9 +2,9 @@ ...@@ -2,9 +2,9 @@
import { visitUrl } from '~/lib/utils/url_utility'; import { visitUrl } from '~/lib/utils/url_utility';
import UsersSelect from '~/users_select'; import UsersSelect from '~/users_select';
import { isMetaClick } from '~/lib/utils/common_utils'; import { isMetaClick } from '~/lib/utils/common_utils';
import { __ } from '../../../../locale'; import { __ } from '~/locale';
import flash from '../../../../flash'; import flash from '~/flash';
import axios from '../../../../lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
export default class Todos { export default class Todos {
constructor() { constructor() {
......
import '~/profile/gl_crop';
import Profile from '~/profile/profile';
document.addEventListener('DOMContentLoaded', () => {
$(document).on('input.ssh_key', '#key_key', function () { // eslint-disable-line func-names
const $title = $('#key_title');
const comment = $(this).val().match(/^\S+ \S+ (.+)\n?$/);
// Extract the SSH Key title from its comment
if (comment && comment.length > 1) {
$title.val(comment[1]).change();
}
});
new Profile(); // eslint-disable-line no-new
});
import initCycleAnalytics from '~/cycle_analytics/cycle_analytics_bundle';
document.addEventListener('DOMContentLoaded', initCycleAnalytics);
import initIssuableSidebar from '~/init_issuable_sidebar'; import initIssuableSidebar from '~/init_issuable_sidebar';
import initSidebarBundle from '~/sidebar/sidebar_bundle';
import Issue from '~/issue'; import Issue from '~/issue';
import ShortcutsIssuable from '~/shortcuts_issuable'; import ShortcutsIssuable from '~/shortcuts_issuable';
import ZenMode from '~/zen_mode'; import ZenMode from '~/zen_mode';
...@@ -10,4 +11,5 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -10,4 +11,5 @@ document.addEventListener('DOMContentLoaded', () => {
new ShortcutsIssuable(); // eslint-disable-line no-new new ShortcutsIssuable(); // eslint-disable-line no-new
new ZenMode(); // eslint-disable-line no-new new ZenMode(); // eslint-disable-line no-new
initIssuableSidebar(); initIssuableSidebar();
initSidebarBundle();
}); });
import initSidebarBundle from '~/sidebar/sidebar_bundle';
import initMergeConflicts from '~/merge_conflicts/merge_conflicts_bundle';
document.addEventListener('DOMContentLoaded', () => {
initSidebarBundle();
initMergeConflicts();
});
...@@ -3,6 +3,7 @@ import ZenMode from '~/zen_mode'; ...@@ -3,6 +3,7 @@ import ZenMode from '~/zen_mode';
import initNotes from '~/init_notes'; import initNotes from '~/init_notes';
import initIssuableSidebar from '~/init_issuable_sidebar'; import initIssuableSidebar from '~/init_issuable_sidebar';
import initDiffNotes from '~/diff_notes/diff_notes_bundle'; import initDiffNotes from '~/diff_notes/diff_notes_bundle';
import initSidebarBundle from '~/sidebar/sidebar_bundle';
import ShortcutsIssuable from '~/shortcuts_issuable'; import ShortcutsIssuable from '~/shortcuts_issuable';
import Diff from '~/diff'; import Diff from '~/diff';
import { handleLocationHash } from '~/lib/utils/common_utils'; import { handleLocationHash } from '~/lib/utils/common_utils';
...@@ -15,12 +16,12 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -15,12 +16,12 @@ document.addEventListener('DOMContentLoaded', () => {
new ZenMode(); // eslint-disable-line no-new new ZenMode(); // eslint-disable-line no-new
initIssuableSidebar(); initIssuableSidebar();
initSidebarBundle();
initNotes(); initNotes();
initDiffNotes(); initDiffNotes();
initPipelines(); initPipelines();
const mrShowNode = document.querySelector('.merge-request'); const mrShowNode = document.querySelector('.merge-request');
window.mergeRequest = new MergeRequest({ window.mergeRequest = new MergeRequest({
action: mrShowNode.dataset.mrAction, action: mrShowNode.dataset.mrAction,
}); });
......
import initSnippet from '~/snippet/snippet_bundle';
import initForm from '~/pages/projects/init_form'; import initForm from '~/pages/projects/init_form';
document.addEventListener('DOMContentLoaded', () => initForm($('.snippet-form'))); document.addEventListener('DOMContentLoaded', () => {
initSnippet();
initForm($('.snippet-form'));
});
import initSnippet from '~/snippet/snippet_bundle';
import initForm from '~/pages/projects/init_form'; import initForm from '~/pages/projects/init_form';
document.addEventListener('DOMContentLoaded', () => initForm($('.snippet-form'))); document.addEventListener('DOMContentLoaded', () => {
initSnippet();
initForm($('.snippet-form'));
});
import initSnippet from '~/snippet/snippet_bundle';
import form from '../form'; import form from '../form';
document.addEventListener('DOMContentLoaded', form); document.addEventListener('DOMContentLoaded', () => {
initSnippet();
form();
});
import initSnippet from '~/snippet/snippet_bundle';
import form from '../form'; import form from '../form';
document.addEventListener('DOMContentLoaded', form); document.addEventListener('DOMContentLoaded', () => {
initSnippet();
form();
});
/* eslint-disable comma-dangle, no-unused-vars, class-methods-use-this, quotes, consistent-return, func-names, prefer-arrow-callback, space-before-function-paren, max-len */ /* eslint-disable comma-dangle, no-unused-vars, class-methods-use-this, quotes, consistent-return, func-names, prefer-arrow-callback, space-before-function-paren, max-len */
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { getPagePath } from '~/lib/utils/common_utils';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale'; import { __ } from '~/locale';
import flash from '../flash'; import flash from '../flash';
((global) => { export default class Profile {
class Profile {
constructor({ form } = {}) { constructor({ form } = {}) {
this.onSubmitForm = this.onSubmitForm.bind(this); this.onSubmitForm = this.onSubmitForm.bind(this);
this.form = form || $('.edit-user'); this.form = form || $('.edit-user');
...@@ -84,20 +82,4 @@ import flash from '../flash'; ...@@ -84,20 +82,4 @@ import flash from '../flash';
multiEditRadios.filter('[value=off]').prop('checked', true); multiEditRadios.filter('[value=off]').prop('checked', true);
} }
} }
} }
$(function() {
$(document).on('input.ssh_key', '#key_key', function() {
const $title = $('#key_title');
const comment = $(this).val().match(/^\S+ \S+ (.+)\n?$/);
// Extract the SSH Key title from its comment
if (comment && comment.length > 1) {
return $title.val(comment[1]).change();
}
});
if (getPagePath() === 'profiles') {
return new Profile();
}
});
})(window.gl || (window.gl = {}));
import './gl_crop';
import './profile';
<script>
export default { export default {
name: 'Assignees', name: 'Assignees',
data() {
return {
defaultRenderCount: 5,
defaultMaxCounter: 99,
showLess: true,
};
},
props: { props: {
rootPath: { rootPath: {
type: String, type: String,
...@@ -21,6 +15,13 @@ export default { ...@@ -21,6 +15,13 @@ export default {
required: true, required: true,
}, },
}, },
data() {
return {
defaultRenderCount: 5,
defaultMaxCounter: 99,
showLess: true,
};
},
computed: { computed: {
firstUser() { firstUser() {
return this.users[0]; return this.users[0];
...@@ -101,7 +102,10 @@ export default { ...@@ -101,7 +102,10 @@ export default {
return index === 0 || firstTwo; return index === 0 || firstTwo;
}, },
}, },
template: ` };
</script>
<template>
<div> <div>
<div <div
class="sidebar-collapsed-icon sidebar-collapsed-user" class="sidebar-collapsed-icon sidebar-collapsed-user"
...@@ -114,12 +118,14 @@ export default { ...@@ -114,12 +118,14 @@ export default {
v-if="hasNoUsers" v-if="hasNoUsers"
aria-label="No Assignee" aria-label="No Assignee"
class="fa fa-user" class="fa fa-user"
/> >
</i>
<button <button
type="button" type="button"
class="btn-link" class="btn-link"
v-for="(user, index) in users" v-for="(user, index) in users"
v-if="shouldRenderCollapsedAssignee(index)" v-if="shouldRenderCollapsedAssignee(index)"
:key="user.id"
> >
<img <img
width="24" width="24"
...@@ -184,9 +190,11 @@ export default { ...@@ -184,9 +190,11 @@ export default {
class="user-item" class="user-item"
v-for="(user, index) in users" v-for="(user, index) in users"
v-if="renderAssignee(index)" v-if="renderAssignee(index)"
:key="user.id"
> >
<a <a
class="user-link has-tooltip" class="user-link has-tooltip"
data-container="body"
data-placement="bottom" data-placement="bottom"
:href="assigneeUrl(user)" :href="assigneeUrl(user)"
:data-title="user.name" :data-title="user.name"
...@@ -220,5 +228,5 @@ export default { ...@@ -220,5 +228,5 @@ export default {
</template> </template>
</div> </div>
</div> </div>
`, </template>
};
import Flash from '../../../flash'; import Flash from '../../../flash';
import AssigneeTitle from './assignee_title'; import AssigneeTitle from './assignee_title';
import Assignees from './assignees'; import Assignees from './assignees.vue';
import Store from '../../stores/sidebar_store'; import Store from '../../stores/sidebar_store';
import eventHub from '../../event_hub'; import eventHub from '../../event_hub';
...@@ -28,8 +28,8 @@ export default { ...@@ -28,8 +28,8 @@ export default {
}, },
}, },
components: { components: {
'assignee-title': AssigneeTitle, AssigneeTitle,
assignees: Assignees, Assignees,
}, },
methods: { methods: {
assignSelf() { assignSelf() {
......
import Mediator from './sidebar_mediator'; import Mediator from './sidebar_mediator';
import { mountSidebar, getSidebarOptions } from './mount_sidebar'; import { mountSidebar, getSidebarOptions } from './mount_sidebar';
function domContentLoaded() { export default () => {
const mediator = new Mediator(getSidebarOptions()); const mediator = new Mediator(getSidebarOptions());
mediator.fetch(); mediator.fetch();
mountSidebar(mediator); mountSidebar(mediator);
} };
document.addEventListener('DOMContentLoaded', domContentLoaded);
export default domContentLoaded;
/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, no-var, quotes, max-len */
/* global ace */ /* global ace */
(function() { export default () => {
$(function() { const editor = ace.edit('editor');
var editor = ace.edit("editor");
$(".snippet-form-holder form").on('submit', function() { $('.snippet-form-holder form').on('submit', () => {
$(".snippet-file-content").val(editor.getValue()); $('.snippet-file-content').val(editor.getValue());
}); });
}); };
}).call(window);
<script> <script>
import tooltip from '../directives/tooltip';
/** /**
* Falls back to the code used in `copy_to_clipboard.js` * Falls back to the code used in `copy_to_clipboard.js`
*/ */
import tooltip from '../directives/tooltip';
export default { export default {
name: 'ClipboardButton', name: 'ClipboardButton',
......
...@@ -11,14 +11,12 @@ ...@@ -11,14 +11,12 @@
default: false, default: false,
required: false, required: false,
}, },
isConfidential: { isConfidential: {
type: Boolean, type: Boolean,
default: false, default: false,
required: false, required: false,
}, },
}, },
computed: { computed: {
warningIcon() { warningIcon() {
if (this.isConfidential) return 'eye-slash'; if (this.isConfidential) return 'eye-slash';
...@@ -26,7 +24,6 @@ ...@@ -26,7 +24,6 @@
return ''; return '';
}, },
isLockedAndConfidential() { isLockedAndConfidential() {
return this.isConfidential && this.isLocked; return this.isConfidential && this.isLocked;
}, },
......
<template>
<li class="timeline-entry note">
<div class="timeline-entry-inner">
<div class="timeline-icon">
</div>
<div class="timeline-content">
<div class="note-header"></div>
<div class="note-body">
<skeleton-loading-container />
</div>
</div>
</div>
</li>
</template>
<script>
import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
export default {
components: {
skeletonLoadingContainer,
},
};
</script>
...@@ -103,6 +103,7 @@ ...@@ -103,6 +103,7 @@
.issuable-show-labels { .issuable-show-labels {
a { a {
margin-bottom: 5px; margin-bottom: 5px;
margin-right: 5px;
display: inline-block; display: inline-block;
.color-label { .color-label {
...@@ -116,6 +117,12 @@ ...@@ -116,6 +117,12 @@
} }
&.has-labels { &.has-labels {
// this font size is a fix to
// prevent unintended spacing between labels
// which shows up when rendering markup has white-space
// characters present.
// see: https://css-tricks.com/fighting-the-space-between-inline-block-elements/#article-header-id-3
font-size: 0;
margin-bottom: -5px; margin-bottom: -5px;
} }
} }
......
...@@ -723,7 +723,7 @@ ul.notes { ...@@ -723,7 +723,7 @@ ul.notes {
.line-resolve-all { .line-resolve-all {
vertical-align: middle; vertical-align: middle;
display: inline-block; display: inline-block;
padding: 5px 10px 6px; padding: 6px 10px;
background-color: $gray-light; background-color: $gray-light;
border: 1px solid $border-color; border: 1px solid $border-color;
border-radius: $border-radius-default; border-radius: $border-radius-default;
......
...@@ -77,6 +77,20 @@ module IssuableActions ...@@ -77,6 +77,20 @@ module IssuableActions
render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" } render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" }
end end
def discussions
notes = issuable.notes
.inc_relations_for_view
.includes(:noteable)
.fresh
notes = prepare_notes_for_rendering(notes)
notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
discussions = Discussion.build_collection(notes, issuable)
render json: DiscussionSerializer.new(project: project, noteable: issuable, current_user: current_user).represent(discussions, context: self)
end
private private
def recaptcha_check_if_spammable(should_redirect = true, &block) def recaptcha_check_if_spammable(should_redirect = true, &block)
......
...@@ -22,7 +22,7 @@ module NotesActions ...@@ -22,7 +22,7 @@ module NotesActions
notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) } notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
notes_json[:notes] = notes_json[:notes] =
if noteable.discussions_rendered_on_frontend? if use_note_serializer?
note_serializer.represent(notes) note_serializer.represent(notes)
else else
notes.map { |note| note_json(note) } notes.map { |note| note_json(note) }
...@@ -95,7 +95,7 @@ module NotesActions ...@@ -95,7 +95,7 @@ module NotesActions
if note.persisted? if note.persisted?
attrs[:valid] = true attrs[:valid] = true
if noteable.discussions_rendered_on_frontend? if use_note_serializer?
attrs.merge!(note_serializer.represent(note)) attrs.merge!(note_serializer.represent(note))
else else
attrs.merge!( attrs.merge!(
...@@ -233,4 +233,14 @@ module NotesActions ...@@ -233,4 +233,14 @@ module NotesActions
the_project the_project
end end
end end
def use_note_serializer?
return false if params['html']
if noteable.is_a?(MergeRequest)
cookies[:vue_mr_discussions] == 'true'
else
noteable.discussions_rendered_on_frontend?
end
end
end end
class Projects::DiscussionsController < Projects::ApplicationController class Projects::DiscussionsController < Projects::ApplicationController
include NotesHelper
include RendersNotes
before_action :check_merge_requests_available! before_action :check_merge_requests_available!
before_action :merge_request before_action :merge_request
before_action :discussion before_action :discussion
...@@ -7,22 +10,45 @@ class Projects::DiscussionsController < Projects::ApplicationController ...@@ -7,22 +10,45 @@ class Projects::DiscussionsController < Projects::ApplicationController
def resolve def resolve
Discussions::ResolveService.new(project, current_user, merge_request: merge_request).execute(discussion) Discussions::ResolveService.new(project, current_user, merge_request: merge_request).execute(discussion)
render json: { render_discussion
resolved_by: discussion.resolved_by.try(:name),
discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
}
end end
def unresolve def unresolve
discussion.unresolve! discussion.unresolve!
render_discussion
end
private
def render_discussion
if serialize_notes?
# TODO - It is not needed to serialize notes when resolving
# or unresolving discussions. We should remove this behavior
# passing a parameter to DiscussionEntity to return an empty array
# for notes.
# Check issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/42853
prepare_notes_for_rendering(discussion.notes, merge_request)
render_json_with_discussions_serializer
else
render_json_with_html
end
end
def render_json_with_discussions_serializer
render json:
DiscussionSerializer.new(project: project, noteable: discussion.noteable, current_user: current_user)
.represent(discussion, context: self)
end
# Legacy method used to render discussions notes when not using Vue on views.
def render_json_with_html
render json: { render json: {
resolved_by: discussion.resolved_by.try(:name),
discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion) discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
} }
end end
private
def merge_request def merge_request
@merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).find_by!(iid: params[:merge_request_id]) @merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).find_by!(iid: params[:merge_request_id])
end end
......
...@@ -60,20 +60,6 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -60,20 +60,6 @@ class Projects::IssuesController < Projects::ApplicationController
respond_with(@issue) respond_with(@issue)
end end
def discussions
notes = @issue.notes
.inc_relations_for_view
.includes(:noteable)
.fresh
notes = prepare_notes_for_rendering(notes)
notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
discussions = Discussion.build_collection(notes, @issue)
render json: DiscussionSerializer.new(project: @project, noteable: @issue, current_user: current_user).represent(discussions)
end
def create def create
create_params = issue_params.merge(spammable_params).merge( create_params = issue_params.merge(spammable_params).merge(
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
......
class Projects::NotesController < Projects::ApplicationController class Projects::NotesController < Projects::ApplicationController
include NotesActions include NotesActions
include NotesHelper
include ToggleAwardEmoji include ToggleAwardEmoji
before_action :whitelist_query_limiting, only: [:create] before_action :whitelist_query_limiting, only: [:create]
...@@ -38,11 +39,15 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -38,11 +39,15 @@ class Projects::NotesController < Projects::ApplicationController
discussion = note.discussion discussion = note.discussion
if serialize_notes?
render_json_with_notes_serializer
else
render json: { render json: {
resolved_by: note.resolved_by.try(:name), resolved_by: note.resolved_by.try(:name),
discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion) discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
} }
end end
end
def unresolve def unresolve
return render_404 unless note.resolvable? return render_404 unless note.resolvable?
...@@ -51,16 +56,27 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -51,16 +56,27 @@ class Projects::NotesController < Projects::ApplicationController
discussion = note.discussion discussion = note.discussion
if serialize_notes?
render_json_with_notes_serializer
else
render json: { render json: {
discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion) discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
} }
end end
end
private private
def render_json_with_notes_serializer
Notes::RenderService.new(current_user).execute([note], project)
render json: note_serializer.represent(note)
end
def note def note
@note ||= @project.notes.find(params[:id]) @note ||= @project.notes.find(params[:id])
end end
alias_method :awardable, :note alias_method :awardable, :note
def finder_params def finder_params
......
...@@ -3,7 +3,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController ...@@ -3,7 +3,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController
before_action :require_pages_enabled! before_action :require_pages_enabled!
before_action :authorize_update_pages!, except: [:show] before_action :authorize_update_pages!, except: [:show]
before_action :domain, only: [:show, :destroy, :verify] before_action :domain, except: [:new, :create]
def show def show
end end
...@@ -24,8 +24,11 @@ class Projects::PagesDomainsController < Projects::ApplicationController ...@@ -24,8 +24,11 @@ class Projects::PagesDomainsController < Projects::ApplicationController
redirect_to project_pages_domain_path(@project, @domain) redirect_to project_pages_domain_path(@project, @domain)
end end
def edit
end
def create def create
@domain = @project.pages_domains.create(pages_domain_params) @domain = @project.pages_domains.create(create_params)
if @domain.valid? if @domain.valid?
redirect_to project_pages_domain_path(@project, @domain) redirect_to project_pages_domain_path(@project, @domain)
...@@ -34,6 +37,16 @@ class Projects::PagesDomainsController < Projects::ApplicationController ...@@ -34,6 +37,16 @@ class Projects::PagesDomainsController < Projects::ApplicationController
end end
end end
def update
if @domain.update(update_params)
redirect_to project_pages_path(@project),
status: 302,
notice: 'Domain was updated'
else
render 'edit'
end
end
def destroy def destroy
@domain.destroy @domain.destroy
...@@ -49,12 +62,12 @@ class Projects::PagesDomainsController < Projects::ApplicationController ...@@ -49,12 +62,12 @@ class Projects::PagesDomainsController < Projects::ApplicationController
private private
def pages_domain_params def create_params
params.require(:pages_domain).permit( params.require(:pages_domain).permit(:key, :certificate, :domain)
:certificate, end
:key,
:domain def update_params
) params.require(:pages_domain).permit(:key, :certificate)
end end
def domain def domain
......
...@@ -151,7 +151,38 @@ module NotesHelper ...@@ -151,7 +151,38 @@ module NotesHelper
} }
end end
def notes_data(issuable)
discussions_path =
if issuable.is_a?(Issue)
discussions_project_issue_path(@project, issuable, format: :json)
else
discussions_project_merge_request_path(@project, issuable, format: :json)
end
{
discussionsPath: discussions_path,
registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'),
newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'),
markdownDocsPath: help_page_path('user/markdown'),
quickActionsDocsPath: help_page_path('user/project/quick_actions'),
closePath: close_issuable_path(issuable),
reopenPath: reopen_issuable_path(issuable),
notesPath: notes_url,
totalNotes: issuable.discussions.length,
lastFetchedAt: Time.now
}.to_json
end
def discussion_resolved_intro(discussion) def discussion_resolved_intro(discussion)
discussion.resolved_by_push? ? 'Automatically resolved' : 'Resolved' discussion.resolved_by_push? ? 'Automatically resolved' : 'Resolved'
end end
def has_vue_discussions_cookie?
cookies[:vue_mr_discussions] == 'true'
end
def serialize_notes?
has_vue_discussions_cookie? && !params['html']
end
end end
...@@ -49,7 +49,7 @@ module Ci ...@@ -49,7 +49,7 @@ module Ci
ref_protected: 1 ref_protected: 1
} }
cached_attr_reader :version, :revision, :platform, :architecture, :contacted_at cached_attr_reader :version, :revision, :platform, :architecture, :contacted_at, :ip_address
# Searches for runners matching the given query. # Searches for runners matching the given query.
# #
...@@ -157,7 +157,7 @@ module Ci ...@@ -157,7 +157,7 @@ module Ci
end end
def update_cached_info(values) def update_cached_info(values)
values = values&.slice(:version, :revision, :platform, :architecture) || {} values = values&.slice(:version, :revision, :platform, :architecture, :ip_address) || {}
values[:contacted_at] = Time.now values[:contacted_at] = Time.now
cache_attributes(values) cache_attributes(values)
......
...@@ -133,6 +133,7 @@ class Note < ActiveRecord::Base ...@@ -133,6 +133,7 @@ class Note < ActiveRecord::Base
def find_discussion(discussion_id) def find_discussion(discussion_id)
notes = where(discussion_id: discussion_id).fresh.to_a notes = where(discussion_id: discussion_id).fresh.to_a
return if notes.empty? return if notes.empty?
Discussion.build(notes) Discussion.build(notes)
......
class DiffFileEntity < Grape::Entity
include DiffHelper
include SubmoduleHelper
include BlobHelper
include IconsHelper
include ActionView::Helpers::TagHelper
expose :submodule?, as: :submodule
expose :submodule_link do |diff_file|
submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository).first
end
expose :blob_path do |diff_file|
diff_file.blob.path
end
expose :blob_icon do |diff_file|
blob_icon(diff_file.b_mode, diff_file.file_path)
end
expose :file_path
expose :deleted_file?, as: :deleted_file
expose :renamed_file?, as: :renamed_file
expose :old_path
expose :new_path
expose :mode_changed?, as: :mode_changed
expose :a_mode
expose :b_mode
expose :text?, as: :text
expose :old_path_html do |diff_file|
old_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path)
old_path
end
expose :new_path_html do |diff_file|
_, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path)
new_path
end
end
...@@ -7,4 +7,42 @@ class DiscussionEntity < Grape::Entity ...@@ -7,4 +7,42 @@ class DiscussionEntity < Grape::Entity
expose :notes, using: NoteEntity expose :notes, using: NoteEntity
expose :individual_note?, as: :individual_note expose :individual_note?, as: :individual_note
expose :resolvable?, as: :resolvable
expose :resolved?, as: :resolved
expose :resolve_path, if: -> (d, _) { d.resolvable? } do |discussion|
resolve_project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion.id)
end
expose :resolve_with_issue_path do |discussion|
new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id)
end
expose :diff_file, using: DiffFileEntity, if: -> (d, _) { defined? d.diff_file }
expose :diff_discussion?, as: :diff_discussion
expose :truncated_diff_lines, if: -> (d, _) { (defined? d.diff_file) && d.diff_file.text? } do |discussion|
options[:context].render_to_string(
partial: "projects/diffs/line",
collection: discussion.truncated_diff_lines,
as: :line,
locals: { diff_file: discussion.diff_file,
discussion_expanded: true,
plain: true },
layout: false,
formats: [:html]
)
end
expose :image_diff_html, if: -> (d, _) { defined? d.diff_file } do |discussion|
diff_file = discussion.diff_file
partial = diff_file.new_file? || diff_file.deleted_file? ? 'single_image_diff' : 'replaced_image_diff'
options[:context].render_to_string(
partial: "projects/diffs/#{partial}",
locals: { diff_file: diff_file,
position: discussion.position.to_json,
click_to_comment: false },
layout: false,
formats: [:html]
)
end
end end
...@@ -115,6 +115,14 @@ class MergeRequestWidgetEntity < IssuableEntity ...@@ -115,6 +115,14 @@ class MergeRequestWidgetEntity < IssuableEntity
expose :can_cherry_pick_on_current_merge_request do |merge_request| expose :can_cherry_pick_on_current_merge_request do |merge_request|
presenter(merge_request).can_cherry_pick_on_current_merge_request? presenter(merge_request).can_cherry_pick_on_current_merge_request?
end end
expose :can_create_note do |issue|
can?(request.current_user, :create_note, issue.project)
end
expose :can_update do |issue|
can?(request.current_user, :update_issue, issue)
end
end end
# Paths # Paths
...@@ -189,6 +197,10 @@ class MergeRequestWidgetEntity < IssuableEntity ...@@ -189,6 +197,10 @@ class MergeRequestWidgetEntity < IssuableEntity
end end
end end
expose :create_note_path do |merge_request|
project_notes_path(merge_request.project, target_type: 'merge_request', target_id: merge_request.id)
end
expose :commit_change_content_path do |merge_request| expose :commit_change_content_path do |merge_request|
commit_change_content_project_merge_request_path(merge_request.project, merge_request) commit_change_content_project_merge_request_path(merge_request.project, merge_request)
end end
......
...@@ -23,6 +23,10 @@ class NoteEntity < API::Entities::Note ...@@ -23,6 +23,10 @@ class NoteEntity < API::Entities::Note
end end
end end
expose :resolved?, as: :resolved
expose :resolvable?, as: :resolvable
expose :resolved_by, using: NoteUserEntity
expose :system_note_icon_name, if: -> (note, _) { note.system? } do |note| expose :system_note_icon_name, if: -> (note, _) { note.system? } do |note|
SystemNoteHelper.system_note_icon_name(note) SystemNoteHelper.system_note_icon_name(note)
end end
...@@ -53,6 +57,14 @@ class NoteEntity < API::Entities::Note ...@@ -53,6 +57,14 @@ class NoteEntity < API::Entities::Note
end end
end end
expose :resolve_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note|
resolve_project_merge_request_discussion_path(note.project, note.noteable, note.discussion_id)
end
expose :resolve_with_issue_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note|
new_project_issue_path(note.project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id)
end
expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? } expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? }
expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note| expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note|
delete_attachment_project_note_path(note.project, note) delete_attachment_project_note_path(note.project, note)
......
...@@ -15,6 +15,8 @@ module Projects ...@@ -15,6 +15,8 @@ module Projects
return error("Could not set the default branch") unless project.change_head(params[:default_branch]) return error("Could not set the default branch") unless project.change_head(params[:default_branch])
end end
ensure_wiki_exists if enabling_wiki?
if project.update_attributes(params.except(:default_branch)) if project.update_attributes(params.except(:default_branch))
if project.previous_changes.include?('path') if project.previous_changes.include?('path')
project.rename_repo project.rename_repo
...@@ -52,5 +54,18 @@ module Projects ...@@ -52,5 +54,18 @@ module Projects
project.repository.exists? && project.repository.exists? &&
new_branch && new_branch != project.default_branch new_branch && new_branch != project.default_branch
end end
def enabling_wiki?
return false if @project.wiki_enabled?
params[:project_feature_attributes][:wiki_access_level].to_i > ProjectFeature::DISABLED
end
def ensure_wiki_exists
ProjectWiki.new(project, project.owner).wiki
rescue ProjectWiki::CouldNotCreateWikiError
log_error("Could not create wiki for #{project.full_name}")
Gitlab::Metrics.counter(:wiki_can_not_be_created_total, 'Counts the times we failed to create a wiki')
end
end end
end end
...@@ -16,6 +16,8 @@ ...@@ -16,6 +16,8 @@
= runner.description = runner.description
%td %td
= runner.version = runner.version
%td
= runner.ip_address
%td %td
- if runner.shared? - if runner.shared?
n/a n/a
......
...@@ -60,6 +60,7 @@ ...@@ -60,6 +60,7 @@
%th Runner token %th Runner token
%th Description %th Description
%th Version %th Version
%th IP Address
%th Projects %th Projects
%th Jobs %th Jobs
%th Tags %th Tags
......
- content_for :page_specific_javascripts do
= webpack_bundle_tag('profile')
- page_title "Account" - page_title "Account"
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
- if current_user.ldap_user? - if current_user.ldap_user?
.alert.alert-info .alert.alert-info
......
- page_title "Authentication log" - page_title "Authentication log"
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
.row.prepend-top-default .row.prepend-top-default
.col-lg-4.profile-settings-sidebar .col-lg-4.profile-settings-sidebar
......
- page_title 'Chat' - page_title 'Chat'
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
.row.prepend-top-default .row.prepend-top-default
.col-lg-4.profile-settings-sidebar .col-lg-4.profile-settings-sidebar
......
- page_title "Emails" - page_title "Emails"
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
.row.prepend-top-default .row.prepend-top-default
.col-lg-4.profile-settings-sidebar .col-lg-4.profile-settings-sidebar
......
- page_title "GPG Keys" - page_title "GPG Keys"
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
.row.prepend-top-default .row.prepend-top-default
.col-lg-4.profile-settings-sidebar .col-lg-4.profile-settings-sidebar
......
- page_title "SSH Keys" - page_title "SSH Keys"
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
.row.prepend-top-default .row.prepend-top-default
.col-lg-4.profile-settings-sidebar .col-lg-4.profile-settings-sidebar
......
...@@ -2,5 +2,4 @@ ...@@ -2,5 +2,4 @@
- breadcrumb_title @key.title - breadcrumb_title @key.title
- page_title @key.title, "SSH Keys" - page_title @key.title, "SSH Keys"
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
= render "key_details" = render "key_details"
- page_title "Notifications" - page_title "Notifications"
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
%div %div
- if @user.errors.any? - if @user.errors.any?
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
- page_title "Personal Access Tokens" - page_title "Personal Access Tokens"
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
.row.prepend-top-default .row.prepend-top-default
.col-lg-4.profile-settings-sidebar .col-lg-4.profile-settings-sidebar
......
- page_title 'Preferences' - page_title 'Preferences'
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
= form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row prepend-top-default js-preferences-form' } do |f| = form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row prepend-top-default js-preferences-form' } do |f|
.col-lg-4.application-theme .col-lg-4.application-theme
......
- breadcrumb_title "Edit Profile" - breadcrumb_title "Edit Profile"
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit' }, authenticity_token: true do |f| = bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit' }, authenticity_token: true do |f|
= form_errors(@user) = form_errors(@user)
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment