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!
Before opening a new issue, make sure to search for keywords in the issues
......@@ -14,10 +15,7 @@ For the Enterprise Edition issue tracker:
- 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.
Please remove this notice if you're confident your issue isn't a duplicate.
------
--->
### Summary
......
......@@ -3,10 +3,10 @@
import AccessorUtilities from './lib/utils/accessor';
export default class Autosave {
constructor(field, key, resource) {
constructor(field, key) {
this.field = field;
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
this.resource = resource;
if (key.join != null) {
key = key.join('/');
}
......@@ -17,31 +17,27 @@ export default class Autosave {
}
restore() {
var text;
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) {
this.field.val(text);
}
if (!this.resource && this.resource !== 'issue') {
this.field.trigger('input');
} else {
// v-model does not update with jQuery trigger
// https://github.com/vuejs/vue/issues/2804#issuecomment-216968137
const event = new Event('change', { bubbles: true, cancelable: false });
const field = this.field.get(0);
if (field) {
field.dispatchEvent(event);
}
}
this.field.trigger('input');
// v-model does not update with jQuery trigger
// https://github.com/vuejs/vue/issues/2804#issuecomment-216968137
const event = new Event('change', { bubbles: true, cancelable: false });
const field = this.field.get(0);
field.dispatchEvent(event);
}
save() {
var text;
text = this.field.val();
if (!this.field.length) return;
const text = this.field.val();
if (this.isLocalStorageAvailable && (text != null ? text.length : void 0) > 0) {
return window.localStorage.setItem(this.key, text);
......
......@@ -2,7 +2,7 @@
import _ from 'underscore';
import Cookies from 'js-cookie';
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 axios from './lib/utils/axios_utils';
......@@ -239,9 +239,9 @@ class AwardsHandler {
}
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_', '');
this.hideMenuElement($('.emoji-menu'));
......@@ -293,8 +293,16 @@ class AwardsHandler {
}
}
isVueMRDiscussions() {
return isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible');
}
isInVueNoteablePage() {
return isInIssuePage() || this.isVueMRDiscussions();
}
getVotesBlock() {
if (isInIssuePage()) {
if (this.isInVueNoteablePage()) {
const $el = $('.js-add-award.is-active').closest('.note.timeline-entry');
if ($el.length) {
......
......@@ -6,7 +6,7 @@ import { __ } from '../../locale';
import Sidebar from '../../right_sidebar';
import eventHub from '../../sidebar/event_hub';
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 './sidebar/remove_issue';
import IssuableContext from '../../issuable_context';
......
......@@ -14,10 +14,10 @@ import CycleAnalyticsStore from './cycle_analytics_store';
Vue.use(Translate);
$(() => {
export default () => {
const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
gl.cycleAnalyticsApp = new Vue({
new Vue({ // eslint-disable-line no-new
el: '#cycle-analytics',
name: 'CycleAnalytics',
components: {
......@@ -132,4 +132,4 @@ $(() => {
},
},
});
});
};
......@@ -197,7 +197,7 @@ const JumpToDiscussion = Vue.extend({
}
$.scrollTo($target, {
offset: 0
offset: -150
});
}
},
......
......@@ -87,6 +87,7 @@ const ResolveBtn = Vue.extend({
CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by);
this.discussion.updateHeadline(data);
gl.mrWidget.checkStatus();
document.dispatchEvent(new CustomEvent('refreshVueNotes'));
this.updateTooltip();
})
......
......@@ -14,6 +14,7 @@ import './components/resolve_count';
import './components/resolve_discussion_btn';
import './components/diff_note_avatars';
import './components/new_issue_for_discussion';
import { hasVueMRDiscussionsCookie } from '../lib/utils/common_utils';
export default () => {
const projectPathHolder = document.querySelector('.merge-request') || document.querySelector('.commit-box');
......@@ -67,12 +68,14 @@ export default () => {
gl.diffNotesCompileComponents();
new Vue({
el: '#resolve-count-app',
components: {
'resolve-count': ResolveCount
},
});
if (!hasVueMRDiscussionsCookie()) {
new Vue({
el: '#resolve-count-app',
components: {
'resolve-count': ResolveCount
},
});
}
$(window).trigger('resize.nav');
};
......@@ -8,8 +8,8 @@ window.gl = window.gl || {};
class ResolveServiceClass {
constructor(root) {
this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve`);
this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve`);
this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve?html=true`);
this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve?html=true`);
}
resolve(noteId) {
......@@ -45,6 +45,7 @@ class ResolveServiceClass {
if (gl.mrWidget) gl.mrWidget.checkStatus();
discussion.updateHeadline(data);
document.dispatchEvent(new CustomEvent('refreshVueNotes'));
})
.catch(() => new Flash('An error occurred when trying to resolve a discussion. Please try again.'));
}
......
<script>
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 icon from '../../vue_shared/components/icon.vue';
import panelResizer from '../../vue_shared/components/panel_resizer.vue';
export default {
components: {
......
<script>
import icon from '~/vue_shared/components/icon.vue';
import repoTree from './ide_repo_tree.vue';
import icon from '../../vue_shared/components/icon.vue';
import newDropdown from './new_dropdown/index.vue';
export default {
......
<script>
import projectAvatarImage from '~/vue_shared/components/project_avatar/image.vue';
import branchesTree from './ide_project_branches_tree.vue';
import projectAvatarImage from '../../vue_shared/components/project_avatar/image.vue';
export default {
components: {
......
<script>
import { mapState } from 'vuex';
import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
import repoPreviousDirectory from './repo_prev_directory.vue';
import repoFile from './repo_file.vue';
import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue';
import { treeList } from '../stores/utils';
export default {
......
<script>
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 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 {
components: {
......
<script>
import { mapState } from 'vuex';
import icon from '../../vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
import timeAgoMixin from '../../vue_shared/mixins/timeago';
import icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import timeAgoMixin from '~/vue_shared/mixins/timeago';
export default {
components: {
......
<script>
import { mapState, mapActions } from 'vuex';
import flash, { hideFlash } from '../../flash';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import flash, { hideFlash } from '~/flash';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
export default {
components: {
......
<script>
import { mapGetters, mapState, mapActions } from 'vuex';
import tooltip from '../../vue_shared/directives/tooltip';
import icon from '../../vue_shared/components/icon.vue';
import modal from '../../vue_shared/components/modal.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import icon from '~/vue_shared/components/icon.vue';
import modal from '~/vue_shared/components/modal.vue';
import commitFilesList from './commit_sidebar/list.vue';
export default {
......
<script>
import { mapGetters, mapActions, mapState } from 'vuex';
import modal from '../../vue_shared/components/modal.vue';
import modal from '~/vue_shared/components/modal.vue';
export default {
components: {
......
<script>
/* global monaco */
import { mapState, mapGetters, mapActions } from 'vuex';
import flash from '../../flash';
import flash from '~/flash';
import monacoLoader from '../monaco_loader';
import Editor from '../lib/editor';
......
<script>
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 {
components: {
......
<script>
import { mapGetters } from 'vuex';
import LineHighlighter from '../../line_highlighter';
import syntaxHighlight from '../../syntax_highlight';
import LineHighlighter from '~/line_highlighter';
import syntaxHighlight from '~/syntax_highlight';
export default {
computed: {
......
import Vue from 'vue';
import { visitUrl } from '../../lib/utils/url_utility';
import flash from '../../flash';
import { visitUrl } from '~/lib/utils/url_utility';
import flash from '~/flash';
import service from '../services';
import * as types from './mutation_types';
import { stripHtml } from '../../lib/utils/text_utility';
......
......@@ -21,7 +21,7 @@ export default class LabelsSelect {
}
$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);
$dropdownContainer = $dropdown.closest('.labels-filter');
$toggleText = $dropdown.find('.dropdown-toggle-text');
......@@ -53,13 +53,6 @@ export default class LabelsSelect {
.map(function () {
return this.value;
}).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;
$sidebarLabelTooltip.tooltip();
......@@ -91,14 +84,17 @@ export default class LabelsSelect {
$loading.fadeOut();
$dropdown.trigger('loaded.gl.dropdown');
$selectbox.hide();
data.issueURLSplit = issueURLSplit;
data.issueUpdateURL = issueUpdateURL;
labelCount = 0;
if (data.labels.length) {
template = labelHTMLTemplate(data);
if (data.labels.length && issueUpdateURL) {
template = LabelsSelect.getLabelTemplate({
labels: data.labels,
issueUpdateURL,
});
labelCount = data.labels.length;
}
else {
template = labelNoneHTMLTemplate;
template = '<span class="no-value">None</span>';
}
$value.removeAttr('style').html(template);
$sidebarCollapsedValue.text(labelCount);
......@@ -418,6 +414,26 @@ export default class LabelsSelect {
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() {
return $('body').on('change', '.selected_issue', this.onSelectCheckboxIssue);
}
......
import jQuery from 'jquery';
import Cookies from 'js-cookie';
import axios from './axios_utils';
import { getLocationHash } from './url_utility';
import { convertToCamelCase } from './text_utility';
......@@ -22,13 +24,18 @@ export const getGroupSlug = () => {
return null;
};
export const isInIssuePage = () => {
const page = getPagePath(1);
const action = getPagePath(2);
export const checkPageAndAction = (page, action) => {
const pagePath = getPagePath(1);
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, {
params: { format: 'js' },
responseType: 'text',
......@@ -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)
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 mrTabsHeight = $('.merge-request-tabs').height() || 0;
const headerHeight = $('.navbar-gitlab').height() || 0;
......
......@@ -65,6 +65,20 @@ export function capitalizeFirstCharacter(text) {
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.
*
......
......@@ -12,7 +12,7 @@ import './components/inline_conflict_lines';
import './components/parallel_conflict_lines';
import syntaxHighlight from '../syntax_highlight';
$(() => {
export default function initMergeConflicts() {
const INTERACTIVE_RESOLVE_MODE = 'interactive';
const conflictsEl = document.querySelector('#conflicts');
const mergeConflictsStore = gl.mergeConflicts.mergeConflictsStore;
......@@ -91,4 +91,4 @@ $(() => {
}
}
});
});
}
......@@ -241,6 +241,10 @@ export default class MergeRequestTabs {
return newState;
}
getCurrentAction() {
return this.currentAction;
}
loadCommits(source) {
if (this.commitsLoaded) {
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');
},
});
});
......@@ -24,7 +24,7 @@ import GLForm from './gl_form';
import loadAwardsHandler from './awards_handler';
import Autosave from './autosave';
import TaskList from './task_list';
import { isInViewport, getPagePath, scrollToElement, isMetaKey } from './lib/utils/common_utils';
import { isInViewport, getPagePath, scrollToElement, isMetaKey, hasVueMRDiscussionsCookie } from './lib/utils/common_utils';
import imageDiffHelper from './image_diff/helpers/index';
import { localTimeAgo } from './lib/utils/datetime_utility';
......@@ -44,6 +44,10 @@ export default class Notes {
}
}
static getInstance() {
return this.instance;
}
constructor(notes_url, note_ids, last_fetched_at, view, enableGFM = true) {
this.updateTargetButtons = this.updateTargetButtons.bind(this);
this.updateComment = this.updateComment.bind(this);
......@@ -102,67 +106,77 @@ export default class Notes {
}
addBinding() {
this.$wrapperEl = hasVueMRDiscussionsCookie() ? $(document).find('.diffs') : $(document);
// Edit note link
$(document).on('click', '.js-note-edit', this.showEditForm.bind(this));
$(document).on('click', '.note-edit-cancel', this.cancelEdit);
this.$wrapperEl.on('click', '.js-note-edit', this.showEditForm.bind(this));
this.$wrapperEl.on('click', '.note-edit-cancel', this.cancelEdit);
// Reopen and close actions for Issue/MR combined with note form submit
$(document).on('click', '.js-comment-submit-button', this.postComment);
$(document).on('click', '.js-comment-save-button', this.updateComment);
$(document).on('keyup input', '.js-note-text', this.updateTargetButtons);
this.$wrapperEl.on('click', '.js-comment-submit-button', this.postComment);
this.$wrapperEl.on('click', '.js-comment-save-button', this.updateComment);
this.$wrapperEl.on('keyup input', '.js-note-text', this.updateTargetButtons);
// resolve a discussion
$(document).on('click', '.js-comment-resolve-button', this.postComment);
this.$wrapperEl.on('click', '.js-comment-resolve-button', this.postComment);
// remove a note (in general)
$(document).on('click', '.js-note-delete', this.removeNote);
this.$wrapperEl.on('click', '.js-note-delete', this.removeNote);
// delete note attachment
$(document).on('click', '.js-note-attachment-delete', this.removeAttachment);
this.$wrapperEl.on('click', '.js-note-attachment-delete', this.removeAttachment);
// reset main target form when clicking discard
$(document).on('click', '.js-note-discard', this.resetMainTargetForm);
this.$wrapperEl.on('click', '.js-note-discard', this.resetMainTargetForm);
// update the file name when an attachment is selected
$(document).on('change', '.js-note-attachment-input', this.updateFormAttachment);
this.$wrapperEl.on('change', '.js-note-attachment-input', this.updateFormAttachment);
// reply to diff/discussion notes
$(document).on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote);
this.$wrapperEl.on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote);
// add diff note
$(document).on('click', '.js-add-diff-note-button', this.onAddDiffNote);
this.$wrapperEl.on('click', '.js-add-diff-note-button', this.onAddDiffNote);
// add diff note for images
$(document).on('click', '.js-add-image-diff-note-button', this.onAddImageDiffNote);
this.$wrapperEl.on('click', '.js-add-image-diff-note-button', this.onAddImageDiffNote);
// hide diff note form
$(document).on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm);
this.$wrapperEl.on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm);
// toggle commit list
$(document).on('click', '.system-note-commit-list-toggler', this.toggleCommitList);
this.$wrapperEl.on('click', '.system-note-commit-list-toggler', this.toggleCommitList);
// fetch notes when tab becomes visible
$(document).on('visibilitychange', this.visibilityChange);
this.$wrapperEl.on('visibilitychange', this.visibilityChange);
// when issue status changes, we need to refresh data
$(document).on('issuable:change', this.refresh);
this.$wrapperEl.on('issuable:change', this.refresh);
// ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs.
$(document).on('ajax:success', '.js-main-target-form', this.addNote);
$(document).on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote);
$(document).on('ajax:success', '.js-main-target-form', this.resetMainTargetForm);
$(document).on('ajax:complete', '.js-main-target-form', this.reenableTargetFormSubmitButton);
this.$wrapperEl.on('ajax:success', '.js-main-target-form', this.addNote);
this.$wrapperEl.on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote);
this.$wrapperEl.on('ajax:success', '.js-main-target-form', this.resetMainTargetForm);
this.$wrapperEl.on('ajax:complete', '.js-main-target-form', this.reenableTargetFormSubmitButton);
// when a key is clicked on the notes
$(document).on('keydown', '.js-note-text', this.keydownNoteText);
this.$wrapperEl.on('keydown', '.js-note-text', this.keydownNoteText);
// When the URL fragment/hash has changed, `#note_xxx`
return $(window).on('hashchange', this.onHashChange);
$(window).on('hashchange', this.onHashChange);
this.boundGetContent = this.getContent.bind(this);
document.addEventListener('refreshLegacyNotes', this.boundGetContent);
this.eventsBound = true;
}
cleanBinding() {
$(document).off('click', '.js-note-edit');
$(document).off('click', '.note-edit-cancel');
$(document).off('click', '.js-note-delete');
$(document).off('click', '.js-note-attachment-delete');
$(document).off('click', '.js-discussion-reply-button');
$(document).off('click', '.js-add-diff-note-button');
$(document).off('click', '.js-add-image-diff-note-button');
$(document).off('visibilitychange');
$(document).off('keyup input', '.js-note-text');
$(document).off('click', '.js-note-target-reopen');
$(document).off('click', '.js-note-target-close');
$(document).off('click', '.js-note-discard');
$(document).off('keydown', '.js-note-text');
$(document).off('click', '.js-comment-resolve-button');
$(document).off('click', '.system-note-commit-list-toggler');
$(document).off('ajax:success', '.js-main-target-form');
$(document).off('ajax:success', '.js-discussion-note-form');
$(document).off('ajax:complete', '.js-main-target-form');
if (!this.eventsBound) {
return;
}
this.$wrapperEl.off('click', '.js-note-edit');
this.$wrapperEl.off('click', '.note-edit-cancel');
this.$wrapperEl.off('click', '.js-note-delete');
this.$wrapperEl.off('click', '.js-note-attachment-delete');
this.$wrapperEl.off('click', '.js-discussion-reply-button');
this.$wrapperEl.off('click', '.js-add-diff-note-button');
this.$wrapperEl.off('click', '.js-add-image-diff-note-button');
this.$wrapperEl.off('visibilitychange');
this.$wrapperEl.off('keyup input', '.js-note-text');
this.$wrapperEl.off('click', '.js-note-target-reopen');
this.$wrapperEl.off('click', '.js-note-target-close');
this.$wrapperEl.off('click', '.js-note-discard');
this.$wrapperEl.off('keydown', '.js-note-text');
this.$wrapperEl.off('click', '.js-comment-resolve-button');
this.$wrapperEl.off('click', '.system-note-commit-list-toggler');
this.$wrapperEl.off('ajax:success', '.js-main-target-form');
this.$wrapperEl.off('ajax:success', '.js-discussion-note-form');
this.$wrapperEl.off('ajax:complete', '.js-main-target-form');
document.removeEventListener('refreshLegacyNotes', this.boundGetContent);
$(window).off('hashchange', this.onHashChange);
}
......@@ -252,8 +266,10 @@ export default class Notes {
if (this.refreshing) {
return;
}
this.refreshing = true;
axios.get(this.notes_url, {
axios.get(`${this.notes_url}?html=true`, {
headers: {
'X-Last-Fetched-At': this.last_fetched_at,
},
......@@ -350,7 +366,7 @@ export default class Notes {
}
if (!noteEntity.valid) {
if (noteEntity.errors.commands_only) {
if (noteEntity.errors && noteEntity.errors.commands_only) {
if (noteEntity.commands_changes &&
Object.keys(noteEntity.commands_changes).length > 0) {
$notesList.find('.system-note.being-posted').remove();
......@@ -363,6 +379,10 @@ export default class Notes {
const $note = $notesList.find(`#note_${noteEntity.id}`);
if (Notes.isNewNote(noteEntity, this.note_ids)) {
if (hasVueMRDiscussionsCookie()) {
return;
}
this.note_ids.push(noteEntity.id);
if ($notesList.length) {
......@@ -399,6 +419,8 @@ export default class Notes {
this.setupNewNote($updatedNote);
}
}
Notes.refreshVueNotes();
}
isParallelView() {
......@@ -406,12 +428,11 @@ export default class Notes {
}
/**
* Render note in discussion area.
*
* Note: for rendering inline notes use renderDiscussionNote
* Render note in discussion area. To render inline notes use renderDiscussionNote.
*/
renderDiscussionNote(noteEntity, $form) {
var discussionContainer, form, row, lineType, diffAvatarContainer;
if (!Notes.isNewNote(noteEntity, this.note_ids)) {
return;
}
......@@ -452,7 +473,9 @@ export default class Notes {
// Init discussion on 'Discussion' page if it is merge request page
const page = $('body').attr('data-page');
if ((page && page.indexOf('projects:merge_request') !== -1) || !noteEntity.diff_discussion_html) {
Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list'));
if (!hasVueMRDiscussionsCookie()) {
Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list'));
}
}
} else {
// append new note to all matching discussions
......@@ -634,7 +657,6 @@ export default class Notes {
var $noteEntityEl, $note_li;
// Convert returned HTML to a jQuery object so we can modify it further
$noteEntityEl = $(noteEntity.html);
$noteEntityEl.addClass('fade-in-full');
this.revertNoteEditForm($targetNote);
$noteEntityEl.renderGFM();
// Find the note's `li` element by ID and replace it with the updated HTML
......@@ -730,7 +752,7 @@ export default class Notes {
var selector = this.getEditFormSelector($target);
var $editForm = $(selector);
$editForm.insertBefore('.notes-form');
$editForm.insertBefore('.diffs');
$editForm.find('.js-comment-save-button').enable();
$editForm.find('.js-finish-edit-warning').hide();
}
......@@ -746,7 +768,8 @@ export default class Notes {
}
removeNoteEditForm($note) {
var form = $note.find('.current-note-edit-form');
var form = $note.find('.diffs .current-note-edit-form');
$note.removeClass('is-editing');
form.removeClass('current-note-edit-form');
form.find('.js-finish-edit-warning').hide();
......@@ -818,6 +841,7 @@ export default class Notes {
};
})(this));
Notes.refreshVueNotes();
Notes.checkMergeRequestStatus();
return this.updateNotesCount(-1);
}
......@@ -1157,7 +1181,7 @@ export default class Notes {
this.glForm = new GLForm($editForm.find('form'), this.enableGFM);
$editForm.find('form')
.attr('action', postUrl)
.attr('action', `${postUrl}?html=true`)
.attr('data-remote', 'true');
$editForm.find('.js-form-target-id').val(targetId);
$editForm.find('.js-form-target-type').val(targetType);
......@@ -1280,6 +1304,10 @@ export default class Notes {
return $updatedNote;
}
static refreshVueNotes() {
document.dispatchEvent(new CustomEvent('refreshVueNotes'));
}
/**
* Get data from Form attributes to use for saving/submitting comment.
*/
......@@ -1481,7 +1509,7 @@ export default class Notes {
/* eslint-disable promise/catch-or-return */
// Make request to submit comment on server
axios.post(formAction, formData)
axios.post(`${formAction}?html=true`, formData)
.then((res) => {
const note = res.data;
......@@ -1546,6 +1574,8 @@ export default class Notes {
if ($notesContainer.length) {
$notesContainer.append('<div class="flash-container" style="display: none;"></div>');
}
Notes.refreshVueNotes();
} else if (isMainForm) { // Check if this was main thread comment
// Show final note element on UI and perform form and action buttons cleanup
this.addNote($form, note);
......@@ -1627,7 +1657,7 @@ export default class Notes {
/* eslint-disable promise/catch-or-return */
// Make request to update comment on server
axios.post(formAction, formData)
axios.post(`${formAction}?html=true`, formData)
.then(({ data }) => {
// Submission successful! render final note element
this.updateNote(data, $editingNote);
......
......@@ -2,10 +2,11 @@
import { mapActions, mapGetters } from 'vuex';
import _ from 'underscore';
import Autosize from 'autosize';
import { __ } from '~/locale';
import { __, sprintf } from '~/locale';
import Flash from '../../flash';
import Autosave from '../../autosave';
import TaskList from '../../task_list';
import { capitalizeFirstCharacter, convertToCamelCase } from '../../lib/utils/text_utility';
import * as constants from '../constants';
import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
......@@ -29,6 +30,12 @@
mixins: [
issuableStateMixin,
],
props: {
noteableType: {
type: String,
required: true,
},
},
data() {
return {
note: '',
......@@ -43,37 +50,51 @@
'getUserData',
'getNoteableData',
'getNotesData',
'issueState',
'openState',
]),
noteableDisplayName() {
return this.noteableType.replace(/_/g, ' ');
},
isLoggedIn() {
return this.getUserData.id;
},
commentButtonTitle() {
return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion';
},
isIssueOpen() {
return this.issueState === constants.OPENED || this.issueState === constants.REOPENED;
isOpen() {
return this.openState === constants.OPENED || this.openState === constants.REOPENED;
},
canCreateNote() {
return this.getNoteableData.current_user.can_create_note;
},
issueActionButtonTitle() {
if (this.note.length) {
const actionText = this.isIssueOpen ? 'close' : 'reopen';
const openOrClose = this.isOpen ? 'close' : 'reopen';
return this.noteType === constants.COMMENT ?
`Comment & ${actionText} issue` :
`Start discussion & ${actionText} issue`;
if (this.note.length) {
return sprintf(
__('%{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() {
return {
'btn-reopen': !this.isIssueOpen,
'btn-close': this.isIssueOpen,
'js-note-target-close': this.isIssueOpen,
'js-note-target-reopen': !this.isIssueOpen,
'btn-reopen': !this.isOpen,
'btn-close': this.isOpen,
'js-note-target-close': this.isOpen,
'js-note-target-reopen': !this.isOpen,
};
},
markdownDocsPath() {
......@@ -138,7 +159,7 @@
flashContainer: this.$el,
data: {
note: {
noteable_type: constants.NOTEABLE_TYPE,
noteable_type: this.noteableType,
noteable_id: this.getNoteableData.id,
note: this.note,
},
......@@ -193,19 +214,29 @@ Please check your network connection and try again.`;
this.isSubmitting = false;
},
toggleIssueState() {
if (this.isIssueOpen) {
if (this.isOpen) {
this.closeIssue()
.then(() => this.enableButton())
.catch(() => {
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 {
this.reopenIssue()
.then(() => this.enableButton())
.catch(() => {
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.`;
this.$refs.markdownField.previewMarkdown = false;
}
// reset autostave
this.autosave.reset();
},
setNoteType(type) {
......@@ -240,10 +270,11 @@ Please check your network connection and try again.`;
},
initAutoSave() {
if (this.isLoggedIn) {
const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType));
this.autosave = new Autosave(
$(this.$refs.textarea),
['Note', 'Issue', this.getNoteableData.id],
'issue',
['Note', noteableType, this.getNoteableData.id],
);
}
},
......@@ -331,7 +362,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
:disabled="isSubmitButtonDisabled"
class="btn btn-create comment-btn js-comment-button js-comment-submit-button"
type="submit">
{{ commentButtonTitle }}
{{ __(commentButtonTitle) }}
</button>
<button
:disabled="isSubmitButtonDisabled"
......@@ -359,7 +390,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
<div class="description">
<strong>Comment</strong>
<p>
Add a general comment to this issue.
Add a general comment to this {{ noteableDisplayName }}.
</p>
</div>
</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 @@
import emojiSmile from 'icons/_emoji_smile.svg';
import emojiSmiley from 'icons/_emoji_smiley.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 loadingIcon from '~/vue_shared/components/loading_icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
......@@ -42,6 +44,26 @@
type: Boolean,
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: {
type: Boolean,
required: true,
......@@ -63,6 +85,15 @@
currentUserId() {
return this.getUserDataByProp('id');
},
resolveButtonTitle() {
let title = 'Mark as resolved';
if (this.resolvedBy) {
title = `Resolved by ${this.resolvedBy.name}`;
}
return title;
},
},
created() {
this.emojiSmiling = emojiSmiling;
......@@ -70,6 +101,8 @@
this.emojiSmiley = emojiSmiley;
this.editSvg = editSvg;
this.ellipsisSvg = ellipsisSvg;
this.resolveDiscussionSvg = resolveDiscussionSvg;
this.resolvedDiscussionSvg = resolvedDiscussionSvg;
},
methods: {
onEdit() {
......@@ -78,6 +111,9 @@
onDelete() {
this.$emit('handleDelete');
},
onResolve() {
this.$emit('handleResolve');
},
},
};
</script>
......@@ -89,6 +125,31 @@
class="note-role user-access-role">
{{ accessLevel }}
</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
v-if="canAddAwardEmoji"
class="note-actions-item">
......
......@@ -41,7 +41,7 @@
this.initTaskList();
if (this.isEditing) {
this.initAutoSave();
this.initAutoSave(this.note.noteable_type);
}
},
updated() {
......@@ -50,7 +50,7 @@
if (this.isEditing) {
if (!this.autosave) {
this.initAutoSave();
this.initAutoSave(this.note.noteable_type);
} else {
this.setAutoSave();
}
......
<script>
import { mapGetters } from 'vuex';
import { mapGetters, mapActions } from 'vuex';
import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
import markdownField from '../../vue_shared/components/markdown/field.vue';
import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
export default {
name: 'IssueNoteForm',
......@@ -13,6 +14,7 @@
},
mixins: [
issuableStateMixin,
resolvable,
],
props: {
noteBody: {
......@@ -30,7 +32,7 @@
required: false,
default: 'Save comment',
},
discussion: {
note: {
type: Object,
required: false,
default: () => ({}),
......@@ -42,9 +44,11 @@
},
data() {
return {
note: this.noteBody,
updatedNoteBody: this.noteBody,
conflictWhileEditing: false,
isSubmitting: false,
isResolving: false,
resolveAsThread: true,
};
},
computed: {
......@@ -71,13 +75,13 @@
return this.getUserDataByProp('id');
},
isDisabled() {
return !this.note.length || this.isSubmitting;
return !this.updatedNoteBody.length || this.isSubmitting;
},
},
watch: {
noteBody() {
if (this.note === this.noteBody) {
this.note = this.noteBody;
if (this.updatedNoteBody === this.noteBody) {
this.updatedNoteBody = this.noteBody;
} else {
this.conflictWhileEditing = true;
}
......@@ -87,16 +91,24 @@
this.$refs.textarea.focus();
},
methods: {
handleUpdate() {
...mapActions([
'toggleResolveNote',
]),
handleUpdate(shouldResolve) {
const beforeSubmitDiscussionState = this.discussionResolved;
this.isSubmitting = true;
this.$emit('handleFormUpdate', this.note, this.$refs.editNoteForm, () => {
this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => {
this.isSubmitting = false;
if (shouldResolve) {
this.resolveHandler(beforeSubmitDiscussionState);
}
});
},
editMyLastNote() {
if (this.note === '') {
const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion);
if (this.updatedNoteBody === '') {
const lastNoteInDiscussion = this.getDiscussionLastNote(this.updatedNoteBody);
if (lastNoteInDiscussion) {
eventHub.$emit('enterEditMode', {
......@@ -107,7 +119,7 @@
},
cancelHandler(shouldConfirm = false) {
// 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 @@
js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
:data-supports-quick-actions="!isEditing"
aria-label="Description"
v-model="note"
v-model="updatedNoteBody"
ref="textarea"
slot="textarea"
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"
class="js-vue-issue-save btn btn-save">
{{ saveButtonTitle }}
</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
@click="cancelHandler()"
class="btn btn-cancel note-edit-cancel"
......
......@@ -34,15 +34,15 @@
required: false,
default: false,
},
},
data() {
return {
isExpanded: true,
};
expanded: {
type: Boolean,
required: false,
default: true,
},
},
computed: {
toggleChevronClass() {
return this.isExpanded ? 'fa-chevron-up' : 'fa-chevron-down';
return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down';
},
noteTimestampLink() {
return `#note_${this.noteId}`;
......@@ -53,7 +53,6 @@
'setTargetNoteHash',
]),
handleToggle() {
this.isExpanded = !this.isExpanded;
this.$emit('toggleHandler');
},
updateTargetNoteHash() {
......
<script>
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 { SYSTEM_NOTE } from '../constants';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
......@@ -8,13 +10,19 @@
import noteSignedOutWidget from './note_signed_out_widget.vue';
import noteEditedText from './note_edited_text.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 placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
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 {
components: {
noteableNote,
diffWithNote,
userAvatarLink,
noteHeader,
noteSignedOutWidget,
......@@ -23,8 +31,13 @@
placeholderNote,
placeholderSystemNote,
},
directives: {
tooltip,
},
mixins: [
autosave,
noteable,
resolvable,
],
props: {
note: {
......@@ -35,14 +48,25 @@
data() {
return {
isReplying: false,
isResolving: false,
resolveAsThread: true,
};
},
computed: {
...mapGetters([
'getNoteableData',
'discussionCount',
'resolvedDiscussionCount',
'unresolvedDiscussions',
]),
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() {
return this.discussion.author;
......@@ -71,26 +95,40 @@
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() {
if (this.isReplying) {
this.initAutoSave();
this.initAutoSave(this.discussion.noteable_type);
}
},
updated() {
if (this.isReplying) {
if (!this.autosave) {
this.initAutoSave();
this.initAutoSave(this.discussion.noteable_type);
} else {
this.setAutoSave();
}
}
},
created() {
this.resolveDiscussionsSvg = resolveDiscussionsSvg;
this.nextDiscussionsSvg = nextDiscussionsSvg;
},
methods: {
...mapActions([
'saveNote',
'toggleDiscussion',
'removePlaceholderNotes',
'toggleResolveNote',
]),
componentName(note) {
if (note.isPlaceholderNote) {
......@@ -103,7 +141,7 @@
return noteableNote;
},
componentData(note) {
return note.isPlaceholderNote ? note.notes[0] : note;
return note.isPlaceholderNote ? this.note.notes[0] : note;
},
toggleDiscussionHandler() {
this.toggleDiscussion({ discussionId: this.note.id });
......@@ -128,7 +166,7 @@
flashContainer: this.$el,
data: {
in_reply_to_discussion_id: this.note.reply_id,
target_type: 'issue',
target_type: this.noteableType,
target_id: this.discussion.noteable_id,
note: { note: noteText },
},
......@@ -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>
<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-icon">
<user-avatar-link
......@@ -175,6 +228,7 @@ Please check your network connection and try again.`;
:created-at="discussion.created_at"
:note-id="discussion.id"
:include-toggle="true"
:expanded="note.expanded"
@toggleHandler="toggleDiscussionHandler"
action-text="started a discussion"
class="discussion"
......@@ -187,43 +241,103 @@ Please check your network connection and try again.`;
class-name="discussion-headline-light js-discussion-headline"
/>
</div>
</div>
<div
v-if="note.expanded"
class="discussion-body">
<div class="panel panel-default">
<div class="discussion-notes">
<ul class="notes">
<component
v-for="note in note.notes"
:is="componentName(note)"
:note="componentData(note)"
:key="note.id"
/>
</ul>
<div
:class="{ 'is-replying': isReplying }"
class="discussion-reply-holder">
<button
v-if="canReply && !isReplying"
@click="showReplyForm"
type="button"
class="js-vue-discussion-reply btn btn-text-field"
title="Add a reply">
Reply...
</button>
<note-form
v-if="isReplying"
save-button-title="Comment"
:discussion="note"
:is-editing="false"
@handleFormUpdate="saveReply"
@cancelFormEdition="cancelReplyForm"
ref="noteForm"
/>
<note-signed-out-widget v-if="!canReply" />
<div
v-if="note.expanded"
class="discussion-body">
<component
:is="wrapperComponent"
:discussion="discussion"
:class="wrapperClass"
>
<div class="discussion-notes">
<ul class="notes">
<component
v-for="note in note.notes"
:is="componentName(note)"
:note="componentData(note)"
:key="note.id"
/>
</ul>
<div
:class="{ 'is-replying': isReplying }"
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
@click="showReplyForm"
type="button"
class="js-vue-discussion-reply btn btn-text-field"
title="Add a reply">Reply...</button>
</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>
</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
v-if="isReplying"
save-button-title="Comment"
:note="note"
:is-editing="false"
@handleFormUpdate="saveReply"
@cancelFormEdition="cancelReplyForm"
ref="noteForm" />
<note-signed-out-widget v-if="!canReply" />
</div>
</div>
</div>
</component>
</div>
</div>
</div>
......
......@@ -7,6 +7,8 @@
import noteActions from './note_actions.vue';
import noteBody from './note_body.vue';
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
export default {
components: {
......@@ -15,6 +17,10 @@
noteActions,
noteBody,
},
mixins: [
noteable,
resolvable,
],
props: {
note: {
type: Object,
......@@ -26,6 +32,7 @@
isEditing: false,
isDeleting: false,
isRequesting: false,
isResolving: false,
};
},
computed: {
......@@ -65,6 +72,7 @@
...mapActions([
'deleteNote',
'updateNote',
'toggleResolveNote',
'scrollToNoteIfNeeded',
]),
editHandler() {
......@@ -89,7 +97,7 @@
const data = {
endpoint: this.note.path,
note: {
target_type: 'issue',
target_type: this.noteableType,
target_id: this.note.noteable_id,
note: { note: noteText },
},
......@@ -134,7 +142,7 @@
// 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.note.note = noteText;
this.$refs.noteBody.$refs.noteForm.note = noteText;
this.$refs.noteBody.$refs.noteForm.note.note = noteText;
},
},
};
......@@ -171,8 +179,13 @@
:can-delete="note.current_user.can_edit"
:can-report-as-abuse="canReportAsAbuse"
:report-abuse-path="note.report_abuse_path"
:resolvable="note.resolvable"
:is-resolved="note.resolved"
:is-resolving="isResolving"
:resolved-by="note.resolved_by"
@handleEdit="editHandler"
@handleDelete="deleteHandler"
@handleResolve="resolveHandler"
/>
</div>
<note-body
......
......@@ -11,6 +11,7 @@
import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue';
export default {
name: 'NotesApp',
......@@ -48,7 +49,24 @@
...mapGetters([
'notes',
'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() {
this.setNotesData(this.notesData);
......@@ -67,6 +85,10 @@
this.actionToggleAward({ awardName, noteId });
});
}
document.addEventListener('refreshVueNotes', this.fetchNotes);
},
beforeDestroy() {
document.removeEventListener('refreshVueNotes', this.fetchNotes);
},
methods: {
...mapActions({
......@@ -81,6 +103,9 @@
setTargetNoteHash: 'setTargetNoteHash',
}),
getComponentName(note) {
if (note.isSkeletonNote) {
return skeletonLoadingContainer;
}
if (note.isPlaceholderNote) {
if (note.placeholderType === constants.SYSTEM_NOTE) {
return placeholderSystemNote;
......@@ -109,9 +134,14 @@
});
},
initPolling() {
if (this.isPollingInitialized) {
return;
}
this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt'));
this.poll();
this.isPollingInitialized = true;
},
checkLocationHash() {
const hash = getLocationHash();
......@@ -128,25 +158,20 @@
<template>
<div id="notes">
<div
v-if="isLoading"
class="js-loading loading">
<loading-icon />
</div>
<ul
v-if="!isLoading"
id="notes-list"
class="notes main-notes-list timeline">
<component
v-for="note in notes"
v-for="note in allNotes"
:is="getComponentName(note)"
:note="getComponentData(note)"
:key="note.id"
/>
</ul>
<comment-form />
<comment-form
:noteable-type="noteableType"
/>
</div>
</template>
export const DISCUSSION_NOTE = 'DiscussionNote';
export const DIFF_NOTE = 'DiffNote';
export const DISCUSSION = 'discussion';
export const NOTE = 'note';
export const SYSTEM_NOTE = 'systemNote';
......@@ -8,4 +9,7 @@ export const REOPENED = 'reopened';
export const CLOSED = 'closed';
export const EMOJI_THUMBSUP = 'thumbsup';
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({
return {
noteableData: JSON.parse(notesDataset.noteableData),
currentUserData,
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,
},
notesData: JSON.parse(notesDataset.notesData),
};
},
render(createElement) {
......
import Autosave from '../../autosave';
import { capitalizeFirstCharacter } from '../../lib/utils/text_utility';
export default {
methods: {
initAutoSave() {
this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', 'Issue', this.note.id], 'issue');
initAutoSave(noteableType) {
this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', capitalizeFirstCharacter(noteableType), this.note.id]);
},
resetAutoSave() {
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 VueResource from 'vue-resource';
import * as constants from '../constants';
Vue.use(VueResource);
......@@ -19,6 +20,12 @@ export default {
createNewNote(endpoint, data) {
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 = {}) {
const { endpoint, lastFetchedAt } = data;
const options = {
......
......@@ -61,8 +61,17 @@ export const createNewNote = ({ commit }, { endpoint, data }) => service
export const removePlaceholderNotes = ({ commit }) =>
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
.toggleIssueState(state.notesData.closeIssuePath)
.toggleIssueState(state.notesData.closePath)
.then(res => res.json())
.then((data) => {
commit(types.CLOSE_ISSUE);
......@@ -70,7 +79,7 @@ export const closeIssue = ({ commit, dispatch, state }) => service
});
export const reopenIssue = ({ commit, dispatch, state }) => service
.toggleIssueState(state.notesData.reopenIssuePath)
.toggleIssueState(state.notesData.reopenPath)
.then(res => res.json())
.then((data) => {
commit(types.REOPEN_ISSUE);
......@@ -80,7 +89,7 @@ export const reopenIssue = ({ commit, dispatch, state }) => service
export const emitStateChangedEvent = ({ commit, getters }, data) => {
const event = new CustomEvent('issuable_vue_app:change', { detail: {
data,
isClosed: getters.issueState === constants.CLOSED,
isClosed: getters.openState === constants.CLOSED,
} });
document.dispatchEvent(event);
......@@ -174,7 +183,7 @@ const pollSuccessCallBack = (resp, commit, state, getters) => {
resp.notes.forEach((note) => {
if (notesById[note.id]) {
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);
if (discussion) {
......
......@@ -8,7 +8,7 @@ export const getNotesDataByProp = state => prop => state.notesData[prop];
export const getNoteableData = state => state.noteableData;
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 getUserDataByProp = state => prop => state.userData && state.userData[prop];
......@@ -30,3 +30,37 @@ export const getCurrentUserLastNote = state => _.flatten(
export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes)
.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';
export const TOGGLE_AWARD = 'TOGGLE_AWARD';
export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION';
export const UPDATE_NOTE = 'UPDATE_NOTE';
export const UPDATE_DISCUSSION = 'UPDATE_DISCUSSION';
// Issue
export const CLOSE_ISSUE = 'CLOSE_ISSUE';
......
import * as utils from './utils';
import * as types from './mutation_types';
import * as constants from '../constants';
import { isInMRPage } from '../../lib/utils/common_utils';
export default {
[types.ADD_NEW_NOTE](state, note) {
const { discussion_id, type } = note;
const [exists] = state.notes.filter(n => n.id === note.discussion_id);
const isDiscussion = (type === constants.DISCUSSION_NOTE);
if (!exists) {
const noteData = {
expanded: true,
id: discussion_id,
individual_note: !(type === constants.DISCUSSION_NOTE),
individual_note: !isDiscussion,
notes: [note],
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);
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
}
},
......@@ -25,6 +35,7 @@ export default {
if (noteObj) {
noteObj.notes.push(note);
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
}
},
......@@ -41,6 +52,8 @@ export default {
state.notes.splice(state.notes.indexOf(noteObj), 1);
}
}
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
},
[types.REMOVE_PLACEHOLDER_NOTES](state) {
......@@ -77,15 +90,19 @@ export default {
const notes = [];
notesData.forEach((note) => {
const nn = Object.assign({}, note);
// To support legacy notes, should be very rare case.
if (note.individual_note && note.notes.length > 1) {
note.notes.forEach((n) => {
const nn = Object.assign({}, note);
nn.notes = [n]; // override notes array to only have one item to mimick individual_note
notes.push(nn);
});
} 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 {
user: { id, name, username },
});
}
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
},
[types.TOGGLE_DISCUSSION](state, { discussionId }) {
......@@ -151,6 +170,24 @@ export default {
const comment = utils.findNoteObjectById(noteObj.notes, note.id);
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) {
......
......@@ -28,4 +28,3 @@ export const getQuickActionText = (note) => {
export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note);
export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim();
......@@ -2,9 +2,9 @@
import { visitUrl } from '~/lib/utils/url_utility';
import UsersSelect from '~/users_select';
import { isMetaClick } from '~/lib/utils/common_utils';
import { __ } from '../../../../locale';
import flash from '../../../../flash';
import axios from '../../../../lib/utils/axios_utils';
import { __ } from '~/locale';
import flash from '~/flash';
import axios from '~/lib/utils/axios_utils';
export default class Todos {
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 initSidebarBundle from '~/sidebar/sidebar_bundle';
import Issue from '~/issue';
import ShortcutsIssuable from '~/shortcuts_issuable';
import ZenMode from '~/zen_mode';
......@@ -10,4 +11,5 @@ document.addEventListener('DOMContentLoaded', () => {
new ShortcutsIssuable(); // eslint-disable-line no-new
new ZenMode(); // eslint-disable-line no-new
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';
import initNotes from '~/init_notes';
import initIssuableSidebar from '~/init_issuable_sidebar';
import initDiffNotes from '~/diff_notes/diff_notes_bundle';
import initSidebarBundle from '~/sidebar/sidebar_bundle';
import ShortcutsIssuable from '~/shortcuts_issuable';
import Diff from '~/diff';
import { handleLocationHash } from '~/lib/utils/common_utils';
......@@ -15,12 +16,12 @@ document.addEventListener('DOMContentLoaded', () => {
new ZenMode(); // eslint-disable-line no-new
initIssuableSidebar();
initSidebarBundle();
initNotes();
initDiffNotes();
initPipelines();
const mrShowNode = document.querySelector('.merge-request');
window.mergeRequest = new MergeRequest({
action: mrShowNode.dataset.mrAction,
});
......
import initSnippet from '~/snippet/snippet_bundle';
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';
document.addEventListener('DOMContentLoaded', () => initForm($('.snippet-form')));
document.addEventListener('DOMContentLoaded', () => {
initSnippet();
initForm($('.snippet-form'));
});
import initSnippet from '~/snippet/snippet_bundle';
import form from '../form';
document.addEventListener('DOMContentLoaded', form);
document.addEventListener('DOMContentLoaded', () => {
initSnippet();
form();
});
import initSnippet from '~/snippet/snippet_bundle';
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 */
import Cookies from 'js-cookie';
import { getPagePath } from '~/lib/utils/common_utils';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import flash from '../flash';
((global) => {
class Profile {
constructor({ form } = {}) {
this.onSubmitForm = this.onSubmitForm.bind(this);
this.form = form || $('.edit-user');
this.newRepoActivated = Cookies.get('new_repo');
this.setRepoRadio();
this.bindEvents();
this.initAvatarGlCrop();
}
initAvatarGlCrop() {
const cropOpts = {
filename: '.js-avatar-filename',
previewImage: '.avatar-image .avatar',
modalCrop: '.modal-profile-crop',
pickImageEl: '.js-choose-user-avatar-button',
uploadImageBtn: '.js-upload-user-avatar',
modalCropImg: '.modal-profile-crop-image'
};
this.avatarGlCrop = $('.js-user-avatar-input').glCrop(cropOpts).data('glcrop');
}
export default class Profile {
constructor({ form } = {}) {
this.onSubmitForm = this.onSubmitForm.bind(this);
this.form = form || $('.edit-user');
this.newRepoActivated = Cookies.get('new_repo');
this.setRepoRadio();
this.bindEvents();
this.initAvatarGlCrop();
}
bindEvents() {
$('.js-preferences-form').on('change.preference', 'input[type=radio]', this.submitForm);
$('input[name="user[multi_file]"]').on('change', this.setNewRepoCookie);
$('#user_notification_email').on('change', this.submitForm);
$('#user_notified_of_own_activity').on('change', this.submitForm);
this.form.on('submit', this.onSubmitForm);
}
initAvatarGlCrop() {
const cropOpts = {
filename: '.js-avatar-filename',
previewImage: '.avatar-image .avatar',
modalCrop: '.modal-profile-crop',
pickImageEl: '.js-choose-user-avatar-button',
uploadImageBtn: '.js-upload-user-avatar',
modalCropImg: '.modal-profile-crop-image'
};
this.avatarGlCrop = $('.js-user-avatar-input').glCrop(cropOpts).data('glcrop');
}
submitForm() {
return $(this).parents('form').submit();
}
bindEvents() {
$('.js-preferences-form').on('change.preference', 'input[type=radio]', this.submitForm);
$('input[name="user[multi_file]"]').on('change', this.setNewRepoCookie);
$('#user_notification_email').on('change', this.submitForm);
$('#user_notified_of_own_activity').on('change', this.submitForm);
this.form.on('submit', this.onSubmitForm);
}
onSubmitForm(e) {
e.preventDefault();
return this.saveForm();
}
submitForm() {
return $(this).parents('form').submit();
}
saveForm() {
const self = this;
const formData = new FormData(this.form[0]);
const avatarBlob = this.avatarGlCrop.getBlob();
onSubmitForm(e) {
e.preventDefault();
return this.saveForm();
}
if (avatarBlob != null) {
formData.append('user[avatar]', avatarBlob, 'avatar.png');
}
saveForm() {
const self = this;
const formData = new FormData(this.form[0]);
const avatarBlob = this.avatarGlCrop.getBlob();
axios({
method: this.form.attr('method'),
url: this.form.attr('action'),
data: formData,
})
.then(({ data }) => flash(data.message, 'notice'))
.then(() => {
window.scrollTo(0, 0);
// Enable submit button after requests ends
self.form.find(':input[disabled]').enable();
})
.catch(error => flash(error.message));
if (avatarBlob != null) {
formData.append('user[avatar]', avatarBlob, 'avatar.png');
}
setNewRepoCookie() {
if (this.value === 'off') {
Cookies.remove('new_repo');
} else {
Cookies.set('new_repo', true, { expires_in: 365 });
}
}
axios({
method: this.form.attr('method'),
url: this.form.attr('action'),
data: formData,
})
.then(({ data }) => flash(data.message, 'notice'))
.then(() => {
window.scrollTo(0, 0);
// Enable submit button after requests ends
self.form.find(':input[disabled]').enable();
})
.catch(error => flash(error.message));
}
setRepoRadio() {
const multiEditRadios = $('input[name="user[multi_file]"]');
if (this.newRepoActivated || this.newRepoActivated === 'true') {
multiEditRadios.filter('[value=on]').prop('checked', true);
} else {
multiEditRadios.filter('[value=off]').prop('checked', true);
}
setNewRepoCookie() {
if (this.value === 'off') {
Cookies.remove('new_repo');
} else {
Cookies.set('new_repo', true, { expires_in: 365 });
}
}
$(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();
setRepoRadio() {
const multiEditRadios = $('input[name="user[multi_file]"]');
if (this.newRepoActivated || this.newRepoActivated === 'true') {
multiEditRadios.filter('[value=on]').prop('checked', true);
} else {
multiEditRadios.filter('[value=off]').prop('checked', true);
}
});
})(window.gl || (window.gl = {}));
}
}
import './gl_crop';
import './profile';
<script>
export default {
name: 'Assignees',
data() {
return {
defaultRenderCount: 5,
defaultMaxCounter: 99,
showLess: true,
};
},
props: {
rootPath: {
type: String,
......@@ -21,6 +15,13 @@ export default {
required: true,
},
},
data() {
return {
defaultRenderCount: 5,
defaultMaxCounter: 99,
showLess: true,
};
},
computed: {
firstUser() {
return this.users[0];
......@@ -101,124 +102,131 @@ export default {
return index === 0 || firstTwo;
},
},
template: `
<div>
<div
class="sidebar-collapsed-icon sidebar-collapsed-user"
:class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }"
data-container="body"
data-placement="left"
:title="collapsedTooltipTitle"
};
</script>
<template>
<div>
<div
class="sidebar-collapsed-icon sidebar-collapsed-user"
:class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }"
data-container="body"
data-placement="left"
:title="collapsedTooltipTitle"
>
<i
v-if="hasNoUsers"
aria-label="No Assignee"
class="fa fa-user"
>
<i
v-if="hasNoUsers"
aria-label="No Assignee"
class="fa fa-user"
</i>
<button
type="button"
class="btn-link"
v-for="(user, index) in users"
v-if="shouldRenderCollapsedAssignee(index)"
:key="user.id"
>
<img
width="24"
class="avatar avatar-inline s24"
:alt="assigneeAlt(user)"
:src="avatarUrl(user)"
/>
<button
type="button"
class="btn-link"
v-for="(user, index) in users"
v-if="shouldRenderCollapsedAssignee(index)"
<span class="author">
{{ user.name }}
</span>
</button>
<button
v-if="hasMoreThanTwoAssignees"
class="btn-link"
type="button"
>
<span
class="avatar-counter sidebar-avatar-counter"
>
{{ sidebarAvatarCounter }}
</span>
</button>
</div>
<div class="value hide-collapsed">
<template v-if="hasNoUsers">
<span class="assign-yourself no-value">
No assignee
<template v-if="editable">
-
<button
type="button"
class="btn-link"
@click="assignSelf"
>
assign yourself
</button>
</template>
</span>
</template>
<template v-else-if="hasOneUser">
<a
class="author_link bold"
:href="assigneeUrl(firstUser)"
>
<img
width="24"
class="avatar avatar-inline s24"
:alt="assigneeAlt(user)"
:src="avatarUrl(user)"
width="32"
class="avatar avatar-inline s32"
:alt="assigneeAlt(firstUser)"
:src="avatarUrl(firstUser)"
/>
<span class="author">
{{ user.name }}
{{ firstUser.name }}
</span>
</button>
<button
v-if="hasMoreThanTwoAssignees"
class="btn-link"
type="button"
>
<span
class="avatar-counter sidebar-avatar-counter"
>
{{ sidebarAvatarCounter }}
<span class="username">
{{ assigneeUsername(firstUser) }}
</span>
</button>
</div>
<div class="value hide-collapsed">
<template v-if="hasNoUsers">
<span class="assign-yourself no-value">
No assignee
<template v-if="editable">
-
<button
type="button"
class="btn-link"
@click="assignSelf"
>
assign yourself
</button>
</template>
</span>
</template>
<template v-else-if="hasOneUser">
<a
class="author_link bold"
:href="assigneeUrl(firstUser)"
>
<img
width="32"
class="avatar avatar-inline s32"
:alt="assigneeAlt(firstUser)"
:src="avatarUrl(firstUser)"
/>
<span class="author">
{{ firstUser.name }}
</span>
<span class="username">
{{ assigneeUsername(firstUser) }}
</span>
</a>
</template>
<template v-else>
<div class="user-list">
<div
class="user-item"
v-for="(user, index) in users"
v-if="renderAssignee(index)"
>
<a
class="user-link has-tooltip"
data-placement="bottom"
:href="assigneeUrl(user)"
:data-title="user.name"
>
<img
width="32"
class="avatar avatar-inline s32"
:alt="assigneeAlt(user)"
:src="avatarUrl(user)"
/>
</a>
</div>
</div>
</a>
</template>
<template v-else>
<div class="user-list">
<div
v-if="renderShowMoreSection"
class="user-list-more"
class="user-item"
v-for="(user, index) in users"
v-if="renderAssignee(index)"
:key="user.id"
>
<button
type="button"
class="btn-link"
@click="toggleShowLess"
<a
class="user-link has-tooltip"
data-container="body"
data-placement="bottom"
:href="assigneeUrl(user)"
:data-title="user.name"
>
<template v-if="showLess">
{{ hiddenAssigneesLabel }}
</template>
<template v-else>
- show less
</template>
</button>
<img
width="32"
class="avatar avatar-inline s32"
:alt="assigneeAlt(user)"
:src="avatarUrl(user)"
/>
</a>
</div>
</template>
</div>
</div>
<div
v-if="renderShowMoreSection"
class="user-list-more"
>
<button
type="button"
class="btn-link"
@click="toggleShowLess"
>
<template v-if="showLess">
{{ hiddenAssigneesLabel }}
</template>
<template v-else>
- show less
</template>
</button>
</div>
</template>
</div>
`,
};
</div>
</template>
import Flash from '../../../flash';
import AssigneeTitle from './assignee_title';
import Assignees from './assignees';
import Assignees from './assignees.vue';
import Store from '../../stores/sidebar_store';
import eventHub from '../../event_hub';
......@@ -28,8 +28,8 @@ export default {
},
},
components: {
'assignee-title': AssigneeTitle,
assignees: Assignees,
AssigneeTitle,
Assignees,
},
methods: {
assignSelf() {
......
import Mediator from './sidebar_mediator';
import { mountSidebar, getSidebarOptions } from './mount_sidebar';
function domContentLoaded() {
export default () => {
const mediator = new Mediator(getSidebarOptions());
mediator.fetch();
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 */
(function() {
$(function() {
var editor = ace.edit("editor");
export default () => {
const editor = ace.edit('editor');
$(".snippet-form-holder form").on('submit', function() {
$(".snippet-file-content").val(editor.getValue());
});
$('.snippet-form-holder form').on('submit', () => {
$('.snippet-file-content').val(editor.getValue());
});
}).call(window);
};
<script>
import tooltip from '../directives/tooltip';
/**
* Falls back to the code used in `copy_to_clipboard.js`
*/
import tooltip from '../directives/tooltip';
export default {
name: 'ClipboardButton',
......
......@@ -11,14 +11,12 @@
default: false,
required: false,
},
isConfidential: {
type: Boolean,
default: false,
required: false,
},
},
computed: {
warningIcon() {
if (this.isConfidential) return 'eye-slash';
......@@ -26,7 +24,6 @@
return '';
},
isLockedAndConfidential() {
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 @@
.issuable-show-labels {
a {
margin-bottom: 5px;
margin-right: 5px;
display: inline-block;
.color-label {
......@@ -116,6 +117,12 @@
}
&.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;
}
}
......
......@@ -723,7 +723,7 @@ ul.notes {
.line-resolve-all {
vertical-align: middle;
display: inline-block;
padding: 5px 10px 6px;
padding: 6px 10px;
background-color: $gray-light;
border: 1px solid $border-color;
border-radius: $border-radius-default;
......
......@@ -77,6 +77,20 @@ module IssuableActions
render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" }
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
def recaptcha_check_if_spammable(should_redirect = true, &block)
......
......@@ -22,7 +22,7 @@ module NotesActions
notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
notes_json[:notes] =
if noteable.discussions_rendered_on_frontend?
if use_note_serializer?
note_serializer.represent(notes)
else
notes.map { |note| note_json(note) }
......@@ -95,7 +95,7 @@ module NotesActions
if note.persisted?
attrs[:valid] = true
if noteable.discussions_rendered_on_frontend?
if use_note_serializer?
attrs.merge!(note_serializer.represent(note))
else
attrs.merge!(
......@@ -233,4 +233,14 @@ module NotesActions
the_project
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
class Projects::DiscussionsController < Projects::ApplicationController
include NotesHelper
include RendersNotes
before_action :check_merge_requests_available!
before_action :merge_request
before_action :discussion
......@@ -7,22 +10,45 @@ class Projects::DiscussionsController < Projects::ApplicationController
def resolve
Discussions::ResolveService.new(project, current_user, merge_request: merge_request).execute(discussion)
render json: {
resolved_by: discussion.resolved_by.try(:name),
discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
}
render_discussion
end
def 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: {
resolved_by: discussion.resolved_by.try(:name),
discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
}
end
private
def merge_request
@merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).find_by!(iid: params[:merge_request_id])
end
......
......@@ -60,20 +60,6 @@ class Projects::IssuesController < Projects::ApplicationController
respond_with(@issue)
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
create_params = issue_params.merge(spammable_params).merge(
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
......
class Projects::NotesController < Projects::ApplicationController
include NotesActions
include NotesHelper
include ToggleAwardEmoji
before_action :whitelist_query_limiting, only: [:create]
......@@ -38,10 +39,14 @@ class Projects::NotesController < Projects::ApplicationController
discussion = note.discussion
render json: {
resolved_by: note.resolved_by.try(:name),
discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
}
if serialize_notes?
render_json_with_notes_serializer
else
render json: {
resolved_by: note.resolved_by.try(:name),
discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
}
end
end
def unresolve
......@@ -51,16 +56,27 @@ class Projects::NotesController < Projects::ApplicationController
discussion = note.discussion
render json: {
discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
}
if serialize_notes?
render_json_with_notes_serializer
else
render json: {
discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
}
end
end
private
def render_json_with_notes_serializer
Notes::RenderService.new(current_user).execute([note], project)
render json: note_serializer.represent(note)
end
def note
@note ||= @project.notes.find(params[:id])
end
alias_method :awardable, :note
def finder_params
......
......@@ -3,7 +3,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController
before_action :require_pages_enabled!
before_action :authorize_update_pages!, except: [:show]
before_action :domain, only: [:show, :destroy, :verify]
before_action :domain, except: [:new, :create]
def show
end
......@@ -24,8 +24,11 @@ class Projects::PagesDomainsController < Projects::ApplicationController
redirect_to project_pages_domain_path(@project, @domain)
end
def edit
end
def create
@domain = @project.pages_domains.create(pages_domain_params)
@domain = @project.pages_domains.create(create_params)
if @domain.valid?
redirect_to project_pages_domain_path(@project, @domain)
......@@ -34,6 +37,16 @@ class Projects::PagesDomainsController < Projects::ApplicationController
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
@domain.destroy
......@@ -49,12 +62,12 @@ class Projects::PagesDomainsController < Projects::ApplicationController
private
def pages_domain_params
params.require(:pages_domain).permit(
:certificate,
:key,
:domain
)
def create_params
params.require(:pages_domain).permit(:key, :certificate, :domain)
end
def update_params
params.require(:pages_domain).permit(:key, :certificate)
end
def domain
......
......@@ -151,7 +151,38 @@ module NotesHelper
}
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)
discussion.resolved_by_push? ? 'Automatically resolved' : 'Resolved'
end
def has_vue_discussions_cookie?
cookies[:vue_mr_discussions] == 'true'
end
def serialize_notes?
has_vue_discussions_cookie? && !params['html']
end
end
......@@ -49,7 +49,7 @@ module Ci
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.
#
......@@ -157,7 +157,7 @@ module Ci
end
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
cache_attributes(values)
......
......@@ -133,6 +133,7 @@ class Note < ActiveRecord::Base
def find_discussion(discussion_id)
notes = where(discussion_id: discussion_id).fresh.to_a
return if notes.empty?
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
expose :notes, using: NoteEntity
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
......@@ -115,6 +115,14 @@ class MergeRequestWidgetEntity < IssuableEntity
expose :can_cherry_pick_on_current_merge_request do |merge_request|
presenter(merge_request).can_cherry_pick_on_current_merge_request?
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
# Paths
......@@ -189,6 +197,10 @@ class MergeRequestWidgetEntity < IssuableEntity
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|
commit_change_content_project_merge_request_path(merge_request.project, merge_request)
end
......
......@@ -23,6 +23,10 @@ class NoteEntity < API::Entities::Note
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|
SystemNoteHelper.system_note_icon_name(note)
end
......@@ -53,6 +57,14 @@ class NoteEntity < API::Entities::Note
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 :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note|
delete_attachment_project_note_path(note.project, note)
......
......@@ -15,6 +15,8 @@ module Projects
return error("Could not set the default branch") unless project.change_head(params[:default_branch])
end
ensure_wiki_exists if enabling_wiki?
if project.update_attributes(params.except(:default_branch))
if project.previous_changes.include?('path')
project.rename_repo
......@@ -52,5 +54,18 @@ module Projects
project.repository.exists? &&
new_branch && new_branch != project.default_branch
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
......@@ -16,6 +16,8 @@
= runner.description
%td
= runner.version
%td
= runner.ip_address
%td
- if runner.shared?
n/a
......
......@@ -60,6 +60,7 @@
%th Runner token
%th Description
%th Version
%th IP Address
%th Projects
%th Jobs
%th Tags
......
- content_for :page_specific_javascripts do
= webpack_bundle_tag('profile')
- page_title "Account"
- @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
- if current_user.ldap_user?
.alert.alert-info
......
- page_title "Authentication log"
- @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
......
- page_title 'Chat'
- @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
......
- page_title "Emails"
- @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
......
- page_title "GPG Keys"
- @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
......
- page_title "SSH Keys"
- @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
......
......@@ -2,5 +2,4 @@
- breadcrumb_title @key.title
- page_title @key.title, "SSH Keys"
- @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
= render "key_details"
- page_title "Notifications"
- @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
%div
- if @user.errors.any?
......
......@@ -2,7 +2,6 @@
- page_title "Personal Access Tokens"
- @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
......
- page_title 'Preferences'
- @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|
.col-lg-4.application-theme
......
- breadcrumb_title "Edit Profile"
- @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|
= form_errors(@user)
......
......@@ -2,7 +2,6 @@
- add_to_breadcrumbs("Two-Factor Authentication", profile_account_path)
- @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
- content_for :page_specific_javascripts do
- if inject_u2f_api?
......
......@@ -2,7 +2,6 @@
- page_title "Cycle Analytics"
- content_for :page_specific_javascripts do
= webpack_bundle_tag('common_vue')
= webpack_bundle_tag('cycle_analytics')
#cycle-analytics{ class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } }
- if @cycle_analytics_no_data
......
......@@ -6,14 +6,6 @@
= link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
%section.js-vue-notes-event
#js-vue-notes{ data: { discussions_path: discussions_project_issue_path(@project, @issue, format: :json),
register_path: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'),
new_session_path: new_session_path(:user, redirect_to_referer: 'yes'),
markdown_docs_path: help_page_path('user/markdown'),
quick_actions_docs_path: help_page_path('user/project/quick_actions'),
notes_path: notes_url,
close_issue_path: issue_path(@issue, issue: { state_event: :close }, format: 'json'),
reopen_issue_path: issue_path(@issue, issue: { state_event: :reopen }, format: 'json'),
last_fetched_at: Time.now.to_i,
noteable_data: serialize_issuable(@issue),
current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json } }
#js-vue-notes{ data: { notes_data: notes_data(@issue),
noteable_data: serialize_issuable(@issue),
current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json } }
......@@ -73,7 +73,7 @@
.content-block.emoji-block
.row
.col-sm-8.js-issue-note-awards
.col-sm-8.js-noteable-awards
= render 'award_emoji/awards_block', awardable: @issue, inline: true
.col-sm-4.new-branch-col
= render 'new_branch' unless @issue.confidential?
......
- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
- content_for :page_specific_javascripts do
= webpack_bundle_tag('common_vue')
= webpack_bundle_tag('merge_conflicts')
= page_specific_javascript_tag('lib/ace.js')
= render "projects/merge_requests/mr_title"
......
- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
- content_for :page_specific_javascripts do
= webpack_bundle_tag('common_vue')
= webpack_bundle_tag('merge_conflicts')
= page_specific_javascript_tag('lib/ace.js')
= render "projects/merge_requests/mr_title"
......
- @gfm_form = true
- @content_class = "limit-container-width" unless fluid_layout
- add_to_breadcrumbs "Merge Requests", project_merge_requests_path(@project)
- breadcrumb_title @merge_request.to_reference
......@@ -7,6 +8,9 @@
- content_for :page_specific_javascripts do
= webpack_bundle_tag('common_vue')
- if has_vue_discussions_cookie?
= webpack_bundle_tag('mr_notes')
.merge-request{ data: { mr_action: j(params[:tab].presence || 'show'), url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project) } }
= render "projects/merge_requests/mr_title"
......@@ -23,7 +27,7 @@
#js-vue-mr-widget.mr-widget
.content-block.content-block-small.emoji-list-container
.content-block.content-block-small.emoji-list-container.js-noteable-awards
= render 'award_emoji/awards_block', awardable: @merge_request, inline: true
.merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') }
......@@ -51,28 +55,37 @@
= tab_link_for @merge_request, :diffs do
Changes
%span.badge= @merge_request.diff_size
#resolve-count-app.line-resolve-all-container.prepend-top-10{ "v-cloak" => true }
%resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" }
%div
.line-resolve-all{ "v-show" => "discussionCount > 0",
":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" }
%span.line-resolve-btn.is-disabled{ type: "button",
":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" }
%template{ 'v-if' => 'resolvedDiscussionCount === discussionCount' }
= render 'shared/icons/icon_status_success_solid.svg'
%template{ 'v-else' => '' }
= render 'shared/icons/icon_resolve_discussion.svg'
%span.line-resolve-text
{{ resolvedDiscussionCount }}/{{ discussionCount }} {{ resolvedCountText }} resolved
= render "discussions/new_issue_for_all_discussions", merge_request: @merge_request
= render "discussions/jump_to_next"
- if has_vue_discussions_cookie?
#js-vue-discussion-counter
- else
#resolve-count-app.line-resolve-all-container.prepend-top-10{ "v-cloak" => true }
%resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" }
%div
.line-resolve-all{ "v-show" => "discussionCount > 0",
":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" }
%span.line-resolve-btn.is-disabled{ type: "button",
":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" }
%template{ 'v-if' => 'resolvedDiscussionCount === discussionCount' }
= render 'shared/icons/icon_status_success_solid.svg'
%template{ 'v-else' => '' }
= render 'shared/icons/icon_resolve_discussion.svg'
%span.line-resolve-text
{{ resolvedDiscussionCount }}/{{ discussionCount }} {{ resolvedCountText }} resolved
= render "discussions/new_issue_for_all_discussions", merge_request: @merge_request
= render "discussions/jump_to_next"
.tab-content#diff-notes-app
#notes.notes.tab-pane.voting_notes
.row
%section.col-md-12
.issuable-discussion
%script.js-notes-data{ type: "application/json" }= initial_notes_data(true).to_json.html_safe
.issuable-discussion.js-vue-notes-event
= render "projects/merge_requests/discussion"
- if has_vue_discussions_cookie?
#js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request),
noteable_data: serialize_issuable(@merge_request),
current_user_data: UserSerializer.new.represent(current_user).to_json} }
#commits.commits.tab-pane
-# This tab is always loaded via AJAX
......
= form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'form-horizontal fieldset-form' } do |f|
- if @domain.errors.any?
#error_explanation
.alert.alert-danger
- @domain.errors.full_messages.each do |msg|
%p= msg
- if @domain.errors.any?
#error_explanation
.alert.alert-danger
- @domain.errors.full_messages.each do |msg|
%p= msg
.form-group
= f.label :domain, class: 'control-label' do
Domain
.col-sm-10
= f.text_field :domain, required: true, autocomplete: 'off', class: 'form-control', disabled: @domain.persisted?
- if Gitlab.config.pages.external_https
.form-group
= f.label :domain, class: 'control-label' do
Domain
= f.label :certificate, class: 'control-label' do
Certificate (PEM)
.col-sm-10
= f.text_field :domain, required: true, autocomplete: 'off', class: 'form-control'
- if Gitlab.config.pages.external_https
.form-group
= f.label :certificate, class: 'control-label' do
Certificate (PEM)
.col-sm-10
= f.text_area :certificate, rows: 5, class: 'form-control'
%span.help-inline Upload a certificate for your domain with all intermediates
.form-group
= f.label :key, class: 'control-label' do
Key (PEM)
.col-sm-10
= f.text_area :key, rows: 5, class: 'form-control'
%span.help-inline Upload a private key for your certificate
- else
.nothing-here-block
Support for custom certificates is disabled.
Ask your system's administrator to enable it.
= f.text_area :certificate, rows: 5, class: 'form-control'
%span.help-inline Upload a certificate for your domain with all intermediates
.form-actions
= f.submit 'Create New Domain', class: "btn btn-save"
.form-group
= f.label :key, class: 'control-label' do
Key (PEM)
.col-sm-10
= f.text_area :key, rows: 5, class: 'form-control'
%span.help-inline Upload a private key for your certificate
- else
.nothing-here-block
Support for custom certificates is disabled.
Ask your system's administrator to enable it.
- add_to_breadcrumbs "Pages", project_pages_path(@project)
- breadcrumb_title @domain.domain
- page_title @domain.domain
%h3.page_title
= @domain.domain
%hr.clearfix
%div
= form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'form-horizontal fieldset-form' } do |f|
= render 'form', { f: f }
.form-actions
= f.submit 'Save Changes', class: "btn btn-save"
- add_to_breadcrumbs "Pages", project_pages_path(@project)
- page_title 'New Pages Domain'
%h3.page_title
New Pages Domain
%hr.clearfix
%div
= render 'form'
= form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'form-horizontal fieldset-form' } do |f|
= render 'form', { f: f }
.form-actions
= f.submit 'Create New Domain', class: "btn btn-save"
- add_to_breadcrumbs "Pages", project_pages_path(@project)
- breadcrumb_title @domain.domain
- page_title "#{@domain.domain}", 'Pages Domains'
- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled?
- if verification_enabled && @domain.unverified?
%p.alert.alert-warning
......@@ -8,6 +11,7 @@
%h3.page-title
Pages Domain
= link_to 'Edit', edit_project_pages_domain_path(@project, @domain), class: 'btn btn-success pull-right'
.table-holder
%table.table
......
......@@ -29,6 +29,11 @@
Token
.col-sm-10
= f.text_field :token, class: 'form-control', readonly: true
.form-group
= label_tag :ip_address, class: 'control-label' do
IP Address
.col-sm-10
= f.text_field :ip_address, class: 'form-control', readonly: true
.form-group
= label_tag :description, class: 'control-label' do
Description
......
......@@ -40,6 +40,9 @@
%tr
%td Version
%td= @runner.version
%tr
%td IP Address
%td= @runner.ip_address
%tr
%td Revision
%td= @runner.revision
......
- todo = issuable_todo(issuable)
- content_for :page_specific_javascripts do
= webpack_bundle_tag('common_vue')
= webpack_bundle_tag('sidebar')
%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: current_user.present? } }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
.issuable-sidebar{ data: { endpoint: "#{issuable_json_path(issuable)}" } }
......
- issuable = @issue || @merge_request
- discussion_locked = issuable&.discussion_locked?
%ul#notes-list.notes.main-notes-list.timeline
= render "shared/notes/notes"
- unless has_vue_discussions_cookie?
%ul#notes-list.notes.main-notes-list.timeline
= render "shared/notes/notes"
= render 'shared/notes/edit_form', project: @project
- if can_create_note?
%ul.notes.notes-form.timeline
%ul.notes.notes-form.timeline{ :class => ('hidden' if has_vue_discussions_cookie?) }
%li.timeline-entry
.timeline-entry-inner
.flash-container.timeline-content
......
- content_for :page_specific_javascripts do
= page_specific_javascript_tag('lib/ace.js')
= webpack_bundle_tag('snippet')
.snippet-form-holder
= form_for @snippet, url: url, html: { class: "form-horizontal snippet-form js-requires-input js-quick-submit common-note-form" } do |f|
......
---
title: Display Runner IP Address
merge_request: 17286
author:
type: added
---
title: Fix code and wiki search results pages when non-ASCII text is displayed
merge_request: 17413
author:
type: fixed
---
title: Enable filtering MR list based on clicked label in MR sidebar
merge_request: 17390
author:
type: fixed
---
title: Make sure wiki exists when it's enabled
merge_request:
author:
type: fixed
---
title: Add Assignees vue component missing data container
merge_request: 17426
author: George Tsiolis
type: fixed
---
title: 'Pages custom domain: allow update of key/certificate'
merge_request: 17376
author: rfwatson
type: changed
---
title: Move Assignees vue component
merge_request: 16952
author: George Tsiolis
type: performance
......@@ -103,4 +103,6 @@ Doorkeeper.configure do
# Some applications require dynamic query parameters on their request_uri
# set to true if you want this to be allowed
# wildcard_redirect_uri false
base_controller 'ApplicationController'
end
......@@ -12,9 +12,14 @@ unless Sidekiq.server?
config.lograge.logger = ActiveSupport::Logger.new(filename)
# Add request parameters to log output
config.lograge.custom_options = lambda do |event|
params = event.payload[:params]
.except(*%w(controller action format))
.each_pair
.map { |k, v| { key: k, value: v } }
payload = {
time: event.time.utc.iso8601(3),
params: event.payload[:params].except(*%w(controller action format)),
params: params,
remote_ip: event.payload[:remote_ip],
user_id: event.payload[:user_id],
username: event.payload[:username]
......
......@@ -55,7 +55,7 @@ constraints(ProjectUrlConstrainer.new) do
end
resource :pages, only: [:show, :destroy] do
resources :domains, only: [:show, :new, :create, :destroy], controller: 'pages_domains', constraints: { id: %r{[^/]+} } do
resources :domains, except: :index, controller: 'pages_domains', constraints: { id: %r{[^/]+} } do
member do
post :verify
end
......@@ -103,6 +103,7 @@ constraints(ProjectUrlConstrainer.new) do
post :toggle_subscription
post :remove_wip
post :assign_related_issues
get :discussions, format: :json
post :rebase
scope constraints: { format: nil }, action: :show do
......
......@@ -44,31 +44,24 @@ function generateEntries() {
const manualEntries = {
balsamiq_viewer: './blob/balsamiq_viewer.js',
common: './commons/index.js',
common_vue: './vue_shared/vue_resource_interceptor.js',
cycle_analytics: './cycle_analytics/cycle_analytics_bundle.js',
environments: './environments/environments_bundle.js',
filtered_search: './filtered_search/filtered_search_bundle.js',
help: './help/help.js',
merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js',
monitoring: './monitoring/monitoring_bundle.js',
mr_notes: './mr_notes/index.js',
notebook_viewer: './blob/notebook_viewer.js',
pdf_viewer: './blob/pdf_viewer.js',
pipelines_details: './pipelines/pipeline_details_bundle.js',
profile: './profile/profile_bundle.js',
project_import_gl: './projects/project_import_gitlab_project.js',
protected_branches: './protected_branches',
protected_tags: './protected_tags',
registry_list: './registry/index.js',
sidebar: './sidebar/sidebar_bundle.js',
snippet: './snippet/snippet_bundle.js',
sketch_viewer: './blob/sketch_viewer.js',
stl_viewer: './blob/stl_viewer.js',
terminal: './terminal/terminal_bundle.js',
ui_development_kit: './ui_development_kit.js',
two_factor_auth: './two_factor_auth.js',
common: './commons/index.js',
common_vue: './vue_shared/vue_resource_interceptor.js',
locale: './locale/index.js',
......@@ -239,13 +232,12 @@ var config = {
name: 'common_vue',
chunks: [
'boards',
'cycle_analytics',
'deploy_keys',
'environments',
'filtered_search',
'groups',
'merge_conflicts',
'monitoring',
'mr_notes',
'notebook_viewer',
'pdf_viewer',
'pipelines',
......@@ -363,7 +355,7 @@ if (IS_DEV_SERVER) {
callback();
})
},
},
}
);
if (DEV_SERVER_LIVERELOAD) {
config.plugins.push(new webpack.HotModuleReplacementPlugin());
......
class AddIpAddressToRunner < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
add_column :ci_runners, :ip_address, :string
end
end
......@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20180216121030) do
ActiveRecord::Schema.define(version: 20180222043024) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
......@@ -437,6 +437,7 @@ ActiveRecord::Schema.define(version: 20180216121030) do
t.boolean "run_untagged", default: true, null: false
t.boolean "locked", default: false, null: false
t.integer "access_level", default: 0, null: false
t.string "ip_address"
end
add_index "ci_runners", ["contacted_at"], name: "index_ci_runners_on_contacted_at", using: :btree
......
---
toc: false
comments: false
---
......@@ -8,15 +7,9 @@ comments: false
Welcome to [GitLab](https://about.gitlab.com/), a Git-based fully featured
platform for software development!
GitLab offers the most scalable Git-based fully integrated platform for software development, with flexible products and subscription plans.
With GitLab self-hosted, you deploy your own GitLab instance on-premises or on a private cloud of your choice. GitLab self-hosted is available for [free and with paid subscriptions](https://about.gitlab.com/products/): Libre, Starter, Premium, and Ultimate.
Every feature available in Libre is also available in Starter, Premium, and Ultimate.
Starter features are also available in Premium and Ultimate, and Premium features are also
available in Ultimate.
GitLab.com is our SaaS offering. It's hosted, managed, and administered by GitLab, with [free and paid plans](https://about.gitlab.com/gitlab-com/) for individuals and teams: Free, Bronze, Silver, and Gold.
GitLab offers the most scalable Git-based fully integrated platform for
software development, with flexible products and subscriptions.
To understand what features you have access to, check the [GitLab subscriptions](#gitlab-subscriptions) below.
## Shortcuts to GitLab's most visited docs
......@@ -124,8 +117,6 @@ Manage your [repositories](user/project/repository/index.md) from the UI (user i
- [GitLab Integration](integration/README.md): Integrate with multiple third-party services with GitLab to allow external issue trackers and external authentication.
- [Trello Power-Up](integration/trello_power_up.md): Integrate with GitLab's Trello Power-Up
----
## Administrator documentation
[Administration documentation](administration/index.md) applies to admin users of GitLab
......@@ -143,3 +134,42 @@ Learn how to contribute to GitLab:
- [Development](development/README.md): All styleguides and explanations how to contribute.
- [Legal](legal/README.md): Contributor license agreements.
- [Writing documentation](development/writing_documentation.md): Contributing to GitLab Docs.
## GitLab subscriptions
You have two options to use GitLab:
- GitLab self-hosted: Install, administer, and maintain your own GitLab instance.
- GitLab.com: GitLab's SaaS offering. You don't need to install anything to use GitLab.com,
you only need to [sign up](https://gitlab.com/users/sign_in) and start using GitLab
straight away.
### GitLab self-hosted
With GitLab self-hosted, you deploy your own GitLab instance on-premises or on a private cloud of your choice. GitLab self-hosted is available for [free and with paid subscriptions](https://about.gitlab.com/products/): Libre, Starter, Premium, and Ultimate.
Every feature available in Libre is also available in Starter, Premium, and Ultimate.
Starter features are also available in Premium and Ultimate, and Premium features are also
available in Ultimate.
### GitLab.com
GitLab.com is hosted, managed, and administered by GitLab, Inc., with
[free and paid subscriptions](https://about.gitlab.com/gitlab-com/) for individuals
and teams: Free, Bronze, Silver, and Gold.
GitLab.com subscriptions grants access
to the same features available in GitLab self-hosted, **expect
[administration](administration/index.md) tools and settings**:
- GitLab.com Free includes the same features available in GitLab Libre
- GitLab.com Bronze includes the same features available in GitLab Starter
- GitLab.com Silver includes the same features available in GitLab Premium
- GitLab.com Gold includes the same features available in GitLab Ultimate
For supporting the open source community and encouraging the development of
open source projects, GitLab grants access to **Gold** features
for all GitLab.com **public** projects, regardless of the subscription.
To know more about GitLab subscriptions and licensing, please refer to the
[GitLab Product Marketing Handbook](https://about.gitlab.com/handbook/marketing/product-marketing/#tiers).
......@@ -56,6 +56,9 @@ future GitLab releases.**
| **CI_RUNNER_DESCRIPTION** | 8.10 | 0.5 | The description of the runner as saved in GitLab |
| **CI_RUNNER_ID** | 8.10 | 0.5 | The unique id of runner being used |
| **CI_RUNNER_TAGS** | 8.10 | 0.5 | The defined runner tags |
| **CI_RUNNER_VERSION** | all | 10.6 | GitLab Runner version that is executing the current job |
| **CI_RUNNER_REVISION** | all | 10.6 | GitLab Runner revision that is executing the current job |
| **CI_RUNNER_EXECUTABLE_ARCH** | all | 10.6 | The OS/architecture of the GitLab Runner executable (note that this is not necessarily the same as the environment of the executor) |
| **CI_PIPELINE_ID** | 8.10 | 0.5 | The unique id of the current pipeline that GitLab CI uses internally |
| **CI_PIPELINE_TRIGGERED** | all | all | The flag to indicate that job was [triggered] |
| **CI_PIPELINE_SOURCE** | 10.0 | all | The source for this pipeline, one of: push, web, trigger, schedule, api, external. Pipelines created before 9.5 will have unknown as source |
......
......@@ -507,6 +507,7 @@ This is the entry point for our store. You can use the following as a guide:
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import * as mutations from './mutations';
Vue.use(Vuex);
......@@ -514,6 +515,7 @@ Vue.use(Vuex);
export default new Vuex.Store({
actions,
getters,
mutations,
state: {
users: [],
},
......
......@@ -109,8 +109,7 @@ in your SAML IdP:
1. Change the value of `issuer` to a unique name, which will identify the application
to the IdP.
1. [Reconfigure][] or [restart GitLab][] for the changes to take effect if you
installed GitLab via Omnibus or from source respectively.
1. For the changes to take effect, you must [reconfigure][] GitLab if you installed via Omnibus or [restart GitLab][] if you installed from source.
1. Register the GitLab SP in your SAML 2.0 IdP, using the application name specified
in `issuer`.
......
......@@ -36,12 +36,16 @@ GFM honors the markdown specification in how [paragraphs and line breaks are han
A paragraph is simply one or more consecutive lines of text, separated by one or more blank lines.
Line-breaks, or softreturns, are rendered if you end a line with two or more spaces:
Roses are red [followed by two or more spaces]
[//]: # (Do *NOT* remove the two ending whitespaces in the following line.)
[//]: # (They are needed for the Markdown text to render correctly.)
Roses are red [followed by two or more spaces]
Violets are blue
Sugar is sweet
Roses are red
[//]: # (Do *NOT* remove the two ending whitespaces in the following line.)
[//]: # (They are needed for the Markdown text to render correctly.)
Roses are red
Violets are blue
Sugar is sweet
......
......@@ -34,7 +34,7 @@ With **[GitLab Enterprise Edition][ee]**, you can also:
- View the deployment process across projects with [Multi-Project Pipeline Graphs](https://docs.gitlab.com/ee/ci/multi_project_pipeline_graphs.html#multi-project-pipeline-graphs) (available only in GitLab Premium)
- Request [approvals](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html) from your managers (available in GitLab Starter)
- [Squash and merge](https://docs.gitlab.com/ee/user/project/merge_requests/squash_and_merge.html) for a cleaner commit history (available in GitLab Starter)
- Analise the impact of your changes with [Code Quality reports](https://docs.gitlab.com/ee/user/project/merge_requests/code_quality_diff.html) (available in GitLab Starter)
- Analyze the impact of your changes with [Code Quality reports](https://docs.gitlab.com/ee/user/project/merge_requests/code_quality_diff.html) (available in GitLab Starter)
## Use cases
......
......@@ -9,16 +9,22 @@ module API
Gitlab::CurrentSettings.runners_registration_token)
end
def get_runner_version_from_params
return unless params['info'].present?
def authenticate_runner!
forbidden! unless current_runner
attributes_for_keys(%w(name version revision platform architecture), params['info'])
current_runner
.update_cached_info(get_runner_details_from_request)
end
def authenticate_runner!
forbidden! unless current_runner
def get_runner_details_from_request
return get_runner_ip unless params['info'].present?
attributes_for_keys(%w(name version revision platform architecture), params['info'])
.merge(get_runner_ip)
end
current_runner.update_cached_info(get_runner_version_from_params)
def get_runner_ip
{ ip_address: request.ip }
end
def current_runner
......
......@@ -16,7 +16,8 @@ module API
optional :tag_list, type: Array[String], desc: %q(List of Runner's tags)
end
post '/' do
attributes = attributes_for_keys [:description, :locked, :run_untagged, :tag_list]
attributes = attributes_for_keys([:description, :locked, :run_untagged, :tag_list])
.merge(get_runner_details_from_request)
runner =
if runner_registration_token_valid?
......@@ -30,7 +31,6 @@ module API
return forbidden! unless runner
if runner.id
runner.update(get_runner_version_from_params)
present runner, with: Entities::RunnerRegistrationDetails
else
not_found!
......
......@@ -59,7 +59,7 @@ module Gitlab
end
def pages(limit: nil)
@repository.gitaly_migrate(:wiki_get_all_pages, status: Gitlab::GitalyClient::MigrationStatus::DISABLED) do |is_enabled|
@repository.gitaly_migrate(:wiki_get_all_pages) do |is_enabled|
if is_enabled
gitaly_get_all_pages
else
......@@ -68,9 +68,8 @@ module Gitlab
end
end
# Disable because of https://gitlab.com/gitlab-org/gitlab-ce/issues/42039
def page(title:, version: nil, dir: nil)
@repository.gitaly_migrate(:wiki_find_page, status: Gitlab::GitalyClient::MigrationStatus::DISABLED) do |is_enabled|
@repository.gitaly_migrate(:wiki_find_page) do |is_enabled|
if is_enabled
gitaly_find_page(title: title, version: version, dir: dir)
else
......
module Gitlab
class SearchResults
class FoundBlob
include EncodingHelper
attr_reader :id, :filename, :basename, :ref, :startline, :data, :project_id
def initialize(opts = {})
......@@ -9,7 +11,7 @@ module Gitlab
@basename = opts.fetch(:basename, nil)
@ref = opts.fetch(:ref, nil)
@startline = opts.fetch(:startline, nil)
@data = opts.fetch(:data, nil)
@data = encode_utf8(opts.fetch(:data, nil))
@per_page = opts.fetch(:per_page, 20)
@project_id = opts.fetch(:project_id, nil)
end
......
......@@ -21,6 +21,8 @@ else
File.open(hook_path, 'w') do |file|
IO.copy_stream(DATA, file)
end
File.chmod(0755, hook_path)
end
# Toggle the harness on or off
......
......@@ -34,6 +34,8 @@ describe Oauth::AuthorizationsController do
end
context 'with valid params' do
render_views
it 'returns 200 code and renders view' do
get :new, params
......
......@@ -71,6 +71,19 @@ describe Projects::DiscussionsController do
expect(response).to have_gitlab_http_status(200)
end
context "when vue_mr_discussions cookie is present" do
before do
allow(controller).to receive(:cookies).and_return(vue_mr_discussions: 'true')
end
it "renders discussion with serializer" do
expect_any_instance_of(DiscussionSerializer).to receive(:represent)
.with(instance_of(Discussion), { context: instance_of(described_class) })
post :resolve, request_params
end
end
end
end
end
......@@ -119,6 +132,19 @@ describe Projects::DiscussionsController do
expect(response).to have_gitlab_http_status(200)
end
context "when vue_mr_discussions cookie is present" do
before do
allow(controller).to receive(:cookies).and_return({ vue_mr_discussions: 'true' })
end
it "renders discussion with serializer" do
expect_any_instance_of(DiscussionSerializer).to receive(:represent)
.with(instance_of(Discussion), { context: instance_of(described_class) })
delete :unresolve, request_params
end
end
end
end
end
......
......@@ -974,7 +974,7 @@ describe Projects::IssuesController do
it 'returns discussion json' do
get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid
expect(json_response.first.keys).to match_array(%w[id reply_id expanded notes individual_note])
expect(json_response.first.keys).to match_array(%w[id reply_id expanded notes diff_discussion individual_note resolvable resolve_with_issue_path resolved])
end
context 'with cross-reference system note', :request_store do
......
......@@ -53,6 +53,66 @@ describe Projects::PagesDomainsController do
end
end
describe 'GET edit' do
it "displays the 'edit' page" do
get(:edit, request_params.merge(id: pages_domain.domain))
expect(response).to have_gitlab_http_status(200)
expect(response).to render_template('edit')
end
end
describe 'PATCH update' do
before do
controller.instance_variable_set(:@domain, pages_domain)
end
let(:pages_domain_params) do
attributes_for(:pages_domain, :with_certificate, :with_key).slice(:key, :certificate)
end
let(:params) do
request_params.merge(id: pages_domain.domain, pages_domain: pages_domain_params)
end
it 'updates the domain' do
expect(pages_domain)
.to receive(:update)
.with(pages_domain_params)
.and_return(true)
patch(:update, params)
end
it 'redirects to the project page' do
patch(:update, params)
expect(flash[:notice]).to eq 'Domain was updated'
expect(response).to redirect_to(project_pages_path(project))
end
context 'the domain is invalid' do
it 'renders the edit action' do
allow(pages_domain).to receive(:update).and_return(false)
patch(:update, params)
expect(response).to render_template('edit')
end
end
context 'the parameters include the domain' do
it 'renders 400 Bad Request' do
expect(pages_domain)
.to receive(:update)
.with(hash_not_including(:domain))
.and_return(true)
patch(:update, params.deep_merge(pages_domain: { domain: 'abc' }))
end
end
end
describe 'POST verify' do
let(:params) { request_params.merge(id: pages_domain.domain) }
......
......@@ -144,7 +144,7 @@ describe 'Merge request > User posts notes', :js do
end
end
describe 'deleting an attachment' do
describe 'deleting attachment on legacy diff note' do
before do
find('.note').hover
......
......@@ -160,6 +160,37 @@ feature 'Pages' do
expect(page).to have_content('my.test.domain.com')
end
describe 'updating the certificate for an existing domain' do
let!(:domain) do
create(:pages_domain, :with_key, :with_certificate, project: project)
end
it 'allows the certificate to be updated' do
visit project_pages_path(project)
within('#content-body') { click_link 'Details' }
click_link 'Edit'
click_button 'Save Changes'
expect(page).to have_content('Domain was updated')
end
context 'when the certificate is invalid' do
it 'tells the user what the problem is' do
visit project_pages_path(project)
within('#content-body') { click_link 'Details' }
click_link 'Edit'
fill_in 'Certificate (PEM)', with: 'invalid data'
click_button 'Save Changes'
expect(page).to have_content('Certificate must be a valid PEM certificate')
expect(page).to have_content('Certificate misses intermediates')
expect(page).to have_content("Key doesn't match the certificate")
end
end
end
end
end
......
......@@ -367,23 +367,6 @@ describe 'Pipelines', :js do
expect(build.reload).to be_canceled
end
end
context 'dropdown jobs list' do
it 'should keep the dropdown open when the user ctr/cmd + clicks in the job name' do
find('.js-builds-dropdown-button').click
dropdown_item = find('.mini-pipeline-graph-dropdown-item').native
%i(alt control).each do |meta_key|
page.driver.browser.action
.key_down(meta_key)
.click(dropdown_item)
.key_up(meta_key)
.perform
end
expect(page).to have_selector('.js-ci-action-icon')
end
end
end
context 'with pagination' do
......
......@@ -75,7 +75,9 @@
"properties": {
"can_remove_source_branch": { "type": "boolean" },
"can_revert_on_current_merge_request": { "type": ["boolean", "null"] },
"can_cherry_pick_on_current_merge_request": { "type": ["boolean", "null"] }
"can_cherry_pick_on_current_merge_request": { "type": ["boolean", "null"] },
"can_create_note": { "type": "boolean" },
"can_update": { "type": "boolean" }
},
"additionalProperties": false
},
......@@ -103,6 +105,7 @@
"merge_ongoing": { "type": "boolean" },
"ff_only_enabled": { "type": ["boolean", false] },
"should_be_rebased": { "type": "boolean" },
"create_note_path": { "type": ["string", "null"] },
"rebase_commit_sha": { "type": ["string", "null"] },
"rebase_in_progress": { "type": "boolean" },
"can_push_to_source_branch": { "type": "boolean" },
......
......@@ -3,28 +3,24 @@ import AccessorUtilities from '~/lib/utils/accessor';
describe('Autosave', () => {
let autosave;
const field = $('<textarea></textarea>');
const key = 'key';
describe('class constructor', () => {
const key = 'key';
const field = jasmine.createSpyObj('field', ['data', 'on']);
beforeEach(() => {
spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.returnValue(true);
spyOn(Autosave.prototype, 'restore');
autosave = new Autosave(field, key);
});
it('should set .isLocalStorageAvailable', () => {
autosave = new Autosave(field, key);
expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled();
expect(autosave.isLocalStorageAvailable).toBe(true);
});
});
describe('restore', () => {
const key = 'key';
const field = jasmine.createSpyObj('field', ['trigger']);
beforeEach(() => {
autosave = {
field,
......@@ -49,24 +45,53 @@ describe('Autosave', () => {
describe('if .isLocalStorageAvailable is `true`', () => {
beforeEach(() => {
autosave.isLocalStorageAvailable = true;
Autosave.prototype.restore.call(autosave);
});
it('should call .getItem', () => {
Autosave.prototype.restore.call(autosave);
expect(window.localStorage.getItem).toHaveBeenCalledWith(key);
});
it('triggers jquery event', () => {
spyOn(autosave.field, 'trigger').and.callThrough();
Autosave.prototype.restore.call(autosave);
expect(
field.trigger,
).toHaveBeenCalled();
});
it('triggers native event', (done) => {
autosave.field.get(0).addEventListener('change', () => {
done();
});
Autosave.prototype.restore.call(autosave);
});
});
describe('if field gets deleted from DOM', () => {
beforeEach(() => {
autosave.field = $('.not-a-real-element');
});
it('does not trigger event', () => {
spyOn(field, 'trigger').and.callThrough();
expect(
field.trigger,
).not.toHaveBeenCalled();
});
});
});
describe('save', () => {
const field = jasmine.createSpyObj('field', ['val']);
beforeEach(() => {
autosave = jasmine.createSpyObj('autosave', ['reset']);
autosave.field = field;
field.val.and.returnValue('value');
field.val('value');
spyOn(window.localStorage, 'setItem');
});
......@@ -97,8 +122,6 @@ describe('Autosave', () => {
});
describe('reset', () => {
const key = 'key';
beforeEach(() => {
autosave = {
key,
......
......@@ -70,8 +70,50 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont
render_merge_request(example.description, merge_request)
end
it 'merge_requests/discussions.json' do |example|
create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request)
render_discussions_json(merge_request, example.description)
end
it 'merge_requests/diff_discussion.json' do |example|
create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request)
render_discussions_json(merge_request, example.description)
end
context 'with image diff' do
let(:merge_request2) { create(:merge_request_with_diffs, :with_image_diffs, source_project: project, title: "Added images") }
let(:image_path) { "files/images/ee_repo_logo.png" }
let(:image_position) do
Gitlab::Diff::Position.new(
old_path: image_path,
new_path: image_path,
width: 100,
height: 100,
x: 1,
y: 1,
position_type: "image",
diff_refs: merge_request2.diff_refs
)
end
it 'merge_requests/image_diff_discussion.json' do |example|
create(:diff_note_on_merge_request, project: project, noteable: merge_request2, position: image_position)
render_discussions_json(merge_request2, example.description)
end
end
private
def render_discussions_json(merge_request, fixture_file_name)
get :discussions,
namespace_id: project.namespace.to_param,
project_id: project,
id: merge_request.to_param,
format: :json
store_frontend_fixture(response, fixture_file_name)
end
def render_merge_request(fixture_file_name, merge_request)
get :show,
namespace_id: project.namespace.to_param,
......
import LabelsSelect from '~/labels_select';
const mockUrl = '/foo/bar/url';
const mockLabels = [
{
id: 26,
title: 'Foo Label',
description: 'Foobar',
color: '#BADA55',
text_color: '#FFFFFF',
},
];
describe('LabelsSelect', () => {
describe('getLabelTemplate', () => {
const label = mockLabels[0];
let $labelEl;
beforeEach(() => {
$labelEl = $(LabelsSelect.getLabelTemplate({
labels: mockLabels,
issueUpdateURL: mockUrl,
}));
});
it('generated label item template has correct label URL', () => {
expect($labelEl.attr('href')).toBe('/foo/bar?label_name[]=Foo%20Label');
});
it('generated label item template has correct label title', () => {
expect($labelEl.find('span.label').text()).toBe(label.title);
});
it('generated label item template has label description as title attribute', () => {
expect($labelEl.find('span.label').attr('title')).toBe(label.description);
});
it('generated label item template has correct label styles', () => {
expect($labelEl.find('span.label').attr('style')).toBe(`background-color: ${label.color}; color: ${label.text_color};`);
});
});
});
import Vue from 'vue';
import Autosize from 'autosize';
import store from '~/notes/stores';
import issueCommentForm from '~/notes/components/comment_form.vue';
import CommentForm from '~/notes/components/comment_form.vue';
import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data';
import { keyboardDownEvent } from '../../issue_show/helpers';
describe('issue_comment_form component', () => {
let vm;
const Component = Vue.extend(issueCommentForm);
const Component = Vue.extend(CommentForm);
let mountComponent;
beforeEach(() => {
mountComponent = () => new Component({
mountComponent = (noteableType = 'issue') => new Component({
propsData: {
noteableType,
},
store,
}).$mount();
});
......@@ -136,6 +139,11 @@ describe('issue_comment_form component', () => {
expect(vm.editCurrentUserLastNote).toHaveBeenCalled();
});
it('inits autosave', () => {
expect(vm.autosave).toBeDefined();
expect(vm.autosave.key).toEqual(`autosave/Note/Issue/${noteableDataMock.id}`);
});
});
describe('event enter', () => {
......@@ -182,6 +190,15 @@ describe('issue_comment_form component', () => {
done();
});
});
it('updates button text with noteable type', (done) => {
vm.noteableType = 'merge_request';
Vue.nextTick(() => {
expect(vm.$el.querySelector('.btn-comment-and-close').textContent.trim()).toEqual('Close merge request');
done();
});
});
});
describe('issue is confidential', () => {
......
import Vue from 'vue';
import DiffFileHeader from '~/notes/components/diff_file_header.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import mountComponent from '../../helpers/vue_mount_component_helper';
const discussionFixture = 'merge_requests/diff_discussion.json';
describe('diff_file_header', () => {
let vm;
const diffDiscussionMock = getJSONFixture(discussionFixture)[0];
const diffFile = convertObjectPropsToCamelCase(diffDiscussionMock.diff_file);
const props = {
diffFile,
};
const Component = Vue.extend(DiffFileHeader);
const selectors = {
get copyButton() {
return vm.$el.querySelector('button[data-original-title="Copy file path to clipboard"]');
},
get fileName() {
return vm.$el.querySelector('.file-title-name');
},
get titleWrapper() {
return vm.$refs.titleWrapper;
},
};
describe('submodule', () => {
beforeEach(() => {
props.diffFile.submodule = true;
props.diffFile.submoduleLink = '<a href="/bha">Submodule</a>';
vm = mountComponent(Component, props);
});
it('shows submoduleLink', () => {
expect(selectors.fileName.innerHTML).toBe(props.diffFile.submoduleLink);
});
it('has button to copy blob path', () => {
expect(selectors.copyButton).toExist();
expect(selectors.copyButton.getAttribute('data-clipboard-text')).toBe(props.diffFile.submoduleLink);
});
});
describe('changed file', () => {
beforeEach(() => {
props.diffFile.submodule = false;
props.diffFile.discussionPath = 'some/discussion/id';
vm = mountComponent(Component, props);
});
it('shows file type icon', () => {
expect(vm.$el.innerHTML).toContain('fa-file-text-o');
});
it('links to discussion path', () => {
expect(selectors.titleWrapper).toExist();
expect(selectors.titleWrapper.tagName).toBe('A');
expect(selectors.titleWrapper.getAttribute('href')).toBe(props.diffFile.discussionPath);
});
it('shows plain title if no link given', () => {
props.diffFile.discussionPath = undefined;
vm = mountComponent(Component, props);
expect(selectors.titleWrapper.tagName).not.toBe('A');
expect(selectors.titleWrapper.href).toBeFalsy();
});
it('has button to copy file path', () => {
expect(selectors.copyButton).toExist();
expect(selectors.copyButton.getAttribute('data-clipboard-text')).toBe(props.diffFile.filePath);
});
it('shows file mode change', (done) => {
vm.diffFile = {
...props.diffFile,
modeChanged: true,
aMode: '100755',
bMode: '100644',
};
Vue.nextTick(() => {
expect(
vm.$refs.fileMode.textContent.trim(),
).toBe('100755 → 100644');
done();
});
});
});
});
import Vue from 'vue';
import DiffWithNote from '~/notes/components/diff_with_note.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import mountComponent from '../../helpers/vue_mount_component_helper';
const discussionFixture = 'merge_requests/diff_discussion.json';
const imageDiscussionFixture = 'merge_requests/image_diff_discussion.json';
describe('diff_with_note', () => {
let vm;
const diffDiscussionMock = getJSONFixture(discussionFixture)[0];
const diffDiscussion = convertObjectPropsToCamelCase(diffDiscussionMock);
const Component = Vue.extend(DiffWithNote);
const props = {
discussion: diffDiscussion,
};
const selectors = {
get container() {
return vm.$refs.fileHolder;
},
get diffTable() {
return this.container.querySelector('.diff-content table');
},
get diffRows() {
return this.container.querySelectorAll('.diff-content .line_holder');
},
get noteRow() {
return this.container.querySelector('.diff-content .notes_holder');
},
};
describe('text diff', () => {
beforeEach(() => {
vm = mountComponent(Component, props);
});
it('shows text diff', () => {
expect(selectors.container).toHaveClass('text-file');
expect(selectors.diffTable).toExist();
});
it('shows diff lines', () => {
expect(selectors.diffRows.length).toBe(12);
});
it('shows notes row', () => {
expect(selectors.noteRow).toExist();
});
});
describe('image diff', () => {
beforeEach(() => {
const imageDiffDiscussionMock = getJSONFixture(imageDiscussionFixture)[0];
props.discussion = convertObjectPropsToCamelCase(imageDiffDiscussionMock);
});
it('shows image diff', () => {
vm = mountComponent(Component, props);
expect(selectors.container).toHaveClass('js-image-file');
expect(selectors.diffTable).not.toExist();
});
});
});
......@@ -24,6 +24,7 @@ describe('note_app', () => {
beforeEach(() => {
jasmine.addMatchers(vueMatchers);
$('body').attr('data-page', 'projects:merge_requests:show');
const IssueNotesApp = Vue.extend(notesApp);
......@@ -119,8 +120,8 @@ describe('note_app', () => {
vm = mountComponent();
});
it('should render loading icon', () => {
expect(vm).toIncludeElement('.js-loading');
it('renders skeleton notes', () => {
expect(vm).toIncludeElement('.animation-container');
});
it('should render form', () => {
......
......@@ -30,17 +30,26 @@ describe('issue_note_body component', () => {
expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html);
});
it('should be render form if user is editing', (done) => {
vm.isEditing = true;
it('should render awards list', () => {
expect(vm.$el.querySelector('.js-awards-block button [data-name="baseball"]')).not.toBeNull();
expect(vm.$el.querySelector('.js-awards-block button [data-name="bath_tone3"]')).not.toBeNull();
});
Vue.nextTick(() => {
expect(vm.$el.querySelector('textarea.js-task-list-field')).toBeDefined();
done();
describe('isEditing', () => {
beforeEach((done) => {
vm.isEditing = true;
Vue.nextTick(done);
});
});
it('should render awards list', () => {
expect(vm.$el.querySelector('.js-awards-block button [data-name="baseball"]')).toBeDefined();
expect(vm.$el.querySelector('.js-awards-block button [data-name="bath_tone3"]')).toBeDefined();
it('renders edit form', () => {
expect(vm.$el.querySelector('textarea.js-task-list-field')).not.toBeNull();
});
it('adds autosave', () => {
const autosaveKey = `autosave/Note/${note.noteable_type}/${note.id}`;
expect(vm.autosave).toExist();
expect(vm.autosave.key).toEqual(autosaveKey);
});
});
});
......@@ -32,6 +32,7 @@ describe('note_header component', () => {
createdAt: '2017-08-02T10:51:58.559Z',
includeToggle: false,
noteId: 1394,
expanded: true,
},
}).$mount();
});
......@@ -68,6 +69,7 @@ describe('note_header component', () => {
createdAt: '2017-08-02T10:51:58.559Z',
includeToggle: true,
noteId: 1395,
expanded: true,
},
}).$mount();
});
......@@ -76,17 +78,35 @@ describe('note_header component', () => {
expect(vm.$el.querySelector('.js-vue-toggle-button')).toBeDefined();
});
it('should toggle the disucssion icon', (done) => {
expect(
vm.$el.querySelector('.js-vue-toggle-button i').classList.contains('fa-chevron-up'),
).toEqual(true);
it('emits toggle event on click', (done) => {
spyOn(vm, '$emit');
vm.$el.querySelector('.js-vue-toggle-button').click();
Vue.nextTick(() => {
expect(vm.$emit).toHaveBeenCalledWith('toggleHandler');
done();
});
});
it('renders up arrow when open', (done) => {
vm.expanded = true;
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.js-vue-toggle-button i').classList,
).toContain('fa-chevron-up');
done();
});
});
it('renders down arrow when closed', (done) => {
vm.expanded = false;
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.js-vue-toggle-button i').classList.contains('fa-chevron-down'),
).toEqual(true);
vm.$el.querySelector('.js-vue-toggle-button i').classList,
).toContain('fa-chevron-down');
done();
});
});
......
......@@ -7,8 +7,9 @@ export const notesDataMock = {
notesPath: '/gitlab-org/gitlab-ce/noteable/issue/98/notes',
quickActionsDocsPath: '/help/user/project/quick_actions',
registerPath: '/users/sign_in?redirect_to_referer=yes#register-pane',
closeIssuePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=close',
reopenIssuePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=reopen',
totalNotes: 1,
closePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=close',
reopenPath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=reopen',
};
export const userDataMock = {
......
......@@ -56,9 +56,9 @@ describe('Getters Notes Store', () => {
});
});
describe('issueState', () => {
describe('openState', () => {
it('should return the issue state', () => {
expect(getters.issueState(state)).toEqual(noteableDataMock.state);
expect(getters.openState(state)).toEqual(noteableDataMock.state);
});
});
});
import mutations from '~/notes/stores/mutations';
import { note, discussionMock, notesDataMock, userDataMock, noteableDataMock, individualNote } from '../mock_data';
describe('Mutation Notes Store', () => {
describe('Notes Store mutations', () => {
describe('ADD_NEW_NOTE', () => {
let state;
let noteData;
......@@ -103,7 +103,8 @@ describe('Mutation Notes Store', () => {
};
mutations.SET_INITIAL_NOTES(state, [note]);
expect(state.notes).toEqual([note]);
expect(state.notes[0].id).toEqual(note.id);
expect(state.notes.length).toEqual(1);
});
});
......
......@@ -34,6 +34,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
describe('Notes', function() {
const FLASH_TYPE_ALERT = 'alert';
const NOTES_POST_PATH = /(.*)\/notes\?html=true$/;
var commentsTemplate = 'merge_requests/merge_request_with_comment.html.raw';
preloadFixtures(commentsTemplate);
......@@ -154,7 +155,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
$form.find('textarea.js-note-text').val(sampleComment);
mock = new MockAdapter(axios);
mock.onPost(/(.*)\/notes$/).reply(200, noteEntity);
mock.onPost(NOTES_POST_PATH).reply(200, noteEntity);
});
afterEach(() => {
......@@ -506,11 +507,11 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
let mock;
function mockNotesPost() {
mock.onPost(/(.*)\/notes$/).reply(200, note);
mock.onPost(NOTES_POST_PATH).reply(200, note);
}
function mockNotesPostError() {
mock.onPost(/(.*)\/notes$/).networkError();
mock.onPost(NOTES_POST_PATH).networkError();
}
beforeEach(() => {
......@@ -631,7 +632,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onPost(/(.*)\/notes$/).reply(200, note);
mock.onPost(NOTES_POST_PATH).reply(200, note);
this.notes = new Notes('', []);
window.gon.current_username = 'root';
......@@ -684,7 +685,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onPost(/(.*)\/notes$/).reply(200, note);
mock.onPost(NOTES_POST_PATH).reply(200, note);
this.notes = new Notes('', []);
window.gon.current_username = 'root';
......
import Vue from 'vue';
import Assignee from '~/sidebar/components/assignees/assignees';
import Assignee from '~/sidebar/components/assignees/assignees.vue';
import UsersMock from './mock_data';
import UsersMockHelper from '../helpers/user_mock_data_helper';
......
# coding: utf-8
require 'spec_helper'
describe Gitlab::ProjectSearchResults do
......@@ -105,6 +106,32 @@ describe Gitlab::ProjectSearchResults do
end
end
context 'when the search returns non-ASCII data' do
context 'with UTF-8' do
let(:results) { project.repository.search_files_by_content("файл", 'master') }
it 'returns results as UTF-8' do
expect(subject.filename).to eq('encoding/russian.rb')
expect(subject.basename).to eq('encoding/russian')
expect(subject.ref).to eq('master')
expect(subject.startline).to eq(1)
expect(subject.data).to eq("Хороший файл")
end
end
context 'with ISO-8859-1' do
let(:search_result) { "master:encoding/iso8859.txt\x001\x00\xC4\xFC\nmaster:encoding/iso8859.txt\x002\x00\nmaster:encoding/iso8859.txt\x003\x00foo\n".force_encoding(Encoding::ASCII_8BIT) }
it 'returns results as UTF-8' do
expect(subject.filename).to eq('encoding/iso8859.txt')
expect(subject.basename).to eq('encoding/iso8859')
expect(subject.ref).to eq('master')
expect(subject.startline).to eq(1)
expect(subject.data).to eq("Äü\n\nfoo")
end
end
end
context "when filename has extension" do
let(:search_result) { "master:CONTRIBUTE.md\x005\x00- [Contribute to GitLab](#contribute-to-gitlab)\n" }
......
......@@ -122,6 +122,15 @@ describe API::Runner do
end
end
end
it "sets the runner's ip_address" do
post api('/runners'),
{ token: registration_token },
{ 'REMOTE_ADDR' => '123.111.123.111' }
expect(response).to have_gitlab_http_status 201
expect(Ci::Runner.first.ip_address).to eq('123.111.123.111')
end
end
describe 'DELETE /api/v4/runners' do
......@@ -422,6 +431,15 @@ describe API::Runner do
end
end
it "sets the runner's ip_address" do
post api('/jobs/request'),
{ token: runner.token },
{ 'User-Agent' => user_agent, 'REMOTE_ADDR' => '123.222.123.222' }
expect(response).to have_gitlab_http_status 201
expect(runner.reload.ip_address).to eq('123.222.123.222')
end
context 'when concurrently updating a job' do
before do
expect_any_instance_of(Ci::Build).to receive(:run!)
......
require 'spec_helper'
describe DiffFileEntity do
include RepoHelpers
let(:project) { create(:project, :repository) }
let(:repository) { project.repository }
let(:commit) { project.commit(sample_commit.id) }
let(:diff_refs) { commit.diff_refs }
let(:diff) { commit.raw_diffs.first }
let(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: diff_refs, repository: repository) }
let(:entity) { described_class.new(diff_file) }
subject { entity.as_json }
it 'exposes correct attributes' do
expect(subject).to include(
:submodule, :submodule_link, :file_path,
:deleted_file, :old_path, :new_path, :mode_changed,
:a_mode, :b_mode, :text, :old_path_html,
:new_path_html
)
end
end
require 'spec_helper'
describe DiscussionEntity do
include RepoHelpers
let(:user) { create(:user) }
let(:note) { create(:discussion_note_on_merge_request) }
let(:discussion) { note.discussion }
let(:request) { double('request') }
let(:controller) { double('controller') }
let(:entity) { described_class.new(discussion, request: request, context: controller) }
subject { entity.as_json }
before do
allow(controller).to receive(:render_to_string)
allow(request).to receive(:current_user).and_return(user)
allow(request).to receive(:noteable).and_return(note.noteable)
end
it 'exposes correct attributes' do
expect(subject).to include(
:id, :expanded, :notes, :individual_note,
:resolvable, :resolved, :resolve_path,
:resolve_with_issue_path, :diff_discussion
)
end
context 'when diff file is present' do
let(:note) { create(:diff_note_on_merge_request) }
it 'exposes diff file attributes' do
expect(subject).to include(:diff_file, :truncated_diff_lines, :image_diff_html)
end
end
end
......@@ -48,4 +48,15 @@ describe NoteEntity do
expect(subject).to include(:system_note_icon_name)
end
end
context 'when note is part of resolvable discussion' do
before do
allow(note).to receive(:part_of_discussion?).and_return(true)
allow(note).to receive(:resolvable?).and_return(true)
end
it 'exposes paths to resolve note' do
expect(subject).to include(:resolve_path, :resolve_with_issue_path)
end
end
end
......@@ -123,6 +123,40 @@ describe Projects::UpdateService do
end
end
context 'when we update project but not enabling a wiki' do
it 'does not try to create an empty wiki' do
FileUtils.rm_rf(project.wiki.repository.path)
result = update_project(project, user, { name: 'test1' })
expect(result).to eq({ status: :success })
expect(project.wiki_repository_exists?).to be false
end
end
context 'when enabling a wiki' do
it 'creates a wiki' do
project.project_feature.update(wiki_access_level: ProjectFeature::DISABLED)
FileUtils.rm_rf(project.wiki.repository.path)
result = update_project(project, user, project_feature_attributes: { wiki_access_level: ProjectFeature::ENABLED })
expect(result).to eq({ status: :success })
expect(project.wiki_repository_exists?).to be true
expect(project.wiki_enabled?).to be true
end
it 'logs an error and creates a metric when wiki can not be created' do
project.project_feature.update(wiki_access_level: ProjectFeature::DISABLED)
expect_any_instance_of(ProjectWiki).to receive(:wiki).and_raise(ProjectWiki::CouldNotCreateWikiError)
expect_any_instance_of(described_class).to receive(:log_error).with("Could not create wiki for #{project.full_name}")
expect(Gitlab::Metrics).to receive(:counter)
update_project(project, user, project_feature_attributes: { wiki_access_level: ProjectFeature::ENABLED })
end
end
context 'when updating a project that contains container images' do
before do
stub_container_registry_config(enabled: true)
......
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