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

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

* master: (41 commits)
  Remove cycle analytics bundle webpack entry point
  Replace whitespace demonstrating line-breaks in GFM docs
  Remove pipelines mini graph spec that wasn't actually testing anything
  Change the wording to reduce confusion about whether you need to reconfigure or restart.
  Add Assignees vue component missing data container
  Set security harness hook to executable after creation
  Fix for open-ended parameter's in lograge causing elastic memory issues
  Docs: explain feature availability in GitLab.com
  Patch 29
  PagesDomain: improve breadcrumbs
  PagesDomain: Add edit/update functionality
  Add new job variables: CI_RUNNER_{REVISION,VERSION,EXECUTABLE_ARCH}
  Re-enable the remaning Wiki Gitaly-Endpoints
  Labels Select Tests
  Fix unnecessary spacing between labels
  Add changelog entry
  Make label filter URL dynamic
  Move the remaining EE-specific JS files to ee/
  Specify base controller for Doorkeeper
  Fix example in Vuex docs
  ...
parents f87d7e90 ccbce7af
<!---
Please read this! Please read this!
Before opening a new issue, make sure to search for keywords in the issues Before opening a new issue, make sure to search for keywords in the issues
...@@ -14,10 +15,7 @@ For the Enterprise Edition issue tracker: ...@@ -14,10 +15,7 @@ For the Enterprise Edition issue tracker:
- https://gitlab.com/gitlab-org/gitlab-ee/issues?label_name%5B%5D=bug - https://gitlab.com/gitlab-org/gitlab-ee/issues?label_name%5B%5D=bug
and verify the issue you're about to submit isn't a duplicate. and verify the issue you're about to submit isn't a duplicate.
--->
Please remove this notice if you're confident your issue isn't a duplicate.
------
### Summary ### Summary
......
...@@ -3,10 +3,10 @@ ...@@ -3,10 +3,10 @@
import AccessorUtilities from './lib/utils/accessor'; import AccessorUtilities from './lib/utils/accessor';
export default class Autosave { export default class Autosave {
constructor(field, key, resource) { constructor(field, key) {
this.field = field; this.field = field;
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
this.resource = resource;
if (key.join != null) { if (key.join != null) {
key = key.join('/'); key = key.join('/');
} }
...@@ -17,31 +17,27 @@ export default class Autosave { ...@@ -17,31 +17,27 @@ export default class Autosave {
} }
restore() { restore() {
var text;
if (!this.isLocalStorageAvailable) return; if (!this.isLocalStorageAvailable) return;
if (!this.field.length) return;
text = window.localStorage.getItem(this.key); const text = window.localStorage.getItem(this.key);
if ((text != null ? text.length : void 0) > 0) { if ((text != null ? text.length : void 0) > 0) {
this.field.val(text); this.field.val(text);
} }
if (!this.resource && this.resource !== 'issue') {
this.field.trigger('input'); this.field.trigger('input');
} else { // v-model does not update with jQuery trigger
// v-model does not update with jQuery trigger // https://github.com/vuejs/vue/issues/2804#issuecomment-216968137
// https://github.com/vuejs/vue/issues/2804#issuecomment-216968137 const event = new Event('change', { bubbles: true, cancelable: false });
const event = new Event('change', { bubbles: true, cancelable: false }); const field = this.field.get(0);
const field = this.field.get(0); field.dispatchEvent(event);
if (field) {
field.dispatchEvent(event);
}
}
} }
save() { save() {
var text; if (!this.field.length) return;
text = this.field.val();
const text = this.field.val();
if (this.isLocalStorageAvailable && (text != null ? text.length : void 0) > 0) { if (this.isLocalStorageAvailable && (text != null ? text.length : void 0) > 0) {
return window.localStorage.setItem(this.key, text); return window.localStorage.setItem(this.key, text);
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import _ from 'underscore'; import _ from 'underscore';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { __ } from './locale'; import { __ } from './locale';
import { isInIssuePage, updateTooltipTitle } from './lib/utils/common_utils'; import { isInIssuePage, isInMRPage, hasVueMRDiscussionsCookie, updateTooltipTitle } from './lib/utils/common_utils';
import flash from './flash'; import flash from './flash';
import axios from './lib/utils/axios_utils'; import axios from './lib/utils/axios_utils';
...@@ -239,9 +239,9 @@ class AwardsHandler { ...@@ -239,9 +239,9 @@ class AwardsHandler {
} }
addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) { addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) {
const isMainAwardsBlock = votesBlock.closest('.js-issue-note-awards').length; const isMainAwardsBlock = votesBlock.closest('.js-noteable-awards').length;
if (isInIssuePage() && !isMainAwardsBlock) { if (this.isInVueNoteablePage() && !isMainAwardsBlock) {
const id = votesBlock.attr('id').replace('note_', ''); const id = votesBlock.attr('id').replace('note_', '');
this.hideMenuElement($('.emoji-menu')); this.hideMenuElement($('.emoji-menu'));
...@@ -293,8 +293,16 @@ class AwardsHandler { ...@@ -293,8 +293,16 @@ class AwardsHandler {
} }
} }
isVueMRDiscussions() {
return isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible');
}
isInVueNoteablePage() {
return isInIssuePage() || this.isVueMRDiscussions();
}
getVotesBlock() { getVotesBlock() {
if (isInIssuePage()) { if (this.isInVueNoteablePage()) {
const $el = $('.js-add-award.is-active').closest('.note.timeline-entry'); const $el = $('.js-add-award.is-active').closest('.note.timeline-entry');
if ($el.length) { if ($el.length) {
......
...@@ -6,7 +6,7 @@ import { __ } from '../../locale'; ...@@ -6,7 +6,7 @@ import { __ } from '../../locale';
import Sidebar from '../../right_sidebar'; import Sidebar from '../../right_sidebar';
import eventHub from '../../sidebar/event_hub'; import eventHub from '../../sidebar/event_hub';
import assigneeTitle from '../../sidebar/components/assignees/assignee_title'; import assigneeTitle from '../../sidebar/components/assignees/assignee_title';
import assignees from '../../sidebar/components/assignees/assignees'; import assignees from '../../sidebar/components/assignees/assignees.vue';
import DueDateSelectors from '../../due_date_select'; import DueDateSelectors from '../../due_date_select';
import './sidebar/remove_issue'; import './sidebar/remove_issue';
import IssuableContext from '../../issuable_context'; import IssuableContext from '../../issuable_context';
......
...@@ -14,10 +14,10 @@ import CycleAnalyticsStore from './cycle_analytics_store'; ...@@ -14,10 +14,10 @@ import CycleAnalyticsStore from './cycle_analytics_store';
Vue.use(Translate); Vue.use(Translate);
$(() => { export default () => {
const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed'; const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
gl.cycleAnalyticsApp = new Vue({ new Vue({ // eslint-disable-line no-new
el: '#cycle-analytics', el: '#cycle-analytics',
name: 'CycleAnalytics', name: 'CycleAnalytics',
components: { components: {
...@@ -132,4 +132,4 @@ $(() => { ...@@ -132,4 +132,4 @@ $(() => {
}, },
}, },
}); });
}); };
...@@ -197,7 +197,7 @@ const JumpToDiscussion = Vue.extend({ ...@@ -197,7 +197,7 @@ const JumpToDiscussion = Vue.extend({
} }
$.scrollTo($target, { $.scrollTo($target, {
offset: 0 offset: -150
}); });
} }
}, },
......
...@@ -87,6 +87,7 @@ const ResolveBtn = Vue.extend({ ...@@ -87,6 +87,7 @@ const ResolveBtn = Vue.extend({
CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by); CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by);
this.discussion.updateHeadline(data); this.discussion.updateHeadline(data);
gl.mrWidget.checkStatus(); gl.mrWidget.checkStatus();
document.dispatchEvent(new CustomEvent('refreshVueNotes'));
this.updateTooltip(); this.updateTooltip();
}) })
......
...@@ -14,6 +14,7 @@ import './components/resolve_count'; ...@@ -14,6 +14,7 @@ import './components/resolve_count';
import './components/resolve_discussion_btn'; import './components/resolve_discussion_btn';
import './components/diff_note_avatars'; import './components/diff_note_avatars';
import './components/new_issue_for_discussion'; import './components/new_issue_for_discussion';
import { hasVueMRDiscussionsCookie } from '../lib/utils/common_utils';
export default () => { export default () => {
const projectPathHolder = document.querySelector('.merge-request') || document.querySelector('.commit-box'); const projectPathHolder = document.querySelector('.merge-request') || document.querySelector('.commit-box');
...@@ -67,12 +68,14 @@ export default () => { ...@@ -67,12 +68,14 @@ export default () => {
gl.diffNotesCompileComponents(); gl.diffNotesCompileComponents();
new Vue({ if (!hasVueMRDiscussionsCookie()) {
el: '#resolve-count-app', new Vue({
components: { el: '#resolve-count-app',
'resolve-count': ResolveCount components: {
}, 'resolve-count': ResolveCount
}); },
});
}
$(window).trigger('resize.nav'); $(window).trigger('resize.nav');
}; };
...@@ -8,8 +8,8 @@ window.gl = window.gl || {}; ...@@ -8,8 +8,8 @@ window.gl = window.gl || {};
class ResolveServiceClass { class ResolveServiceClass {
constructor(root) { constructor(root) {
this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve`); this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve?html=true`);
this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve`); this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve?html=true`);
} }
resolve(noteId) { resolve(noteId) {
...@@ -45,6 +45,7 @@ class ResolveServiceClass { ...@@ -45,6 +45,7 @@ class ResolveServiceClass {
if (gl.mrWidget) gl.mrWidget.checkStatus(); if (gl.mrWidget) gl.mrWidget.checkStatus();
discussion.updateHeadline(data); discussion.updateHeadline(data);
document.dispatchEvent(new CustomEvent('refreshVueNotes'));
}) })
.catch(() => new Flash('An error occurred when trying to resolve a discussion. Please try again.')); .catch(() => new Flash('An error occurred when trying to resolve a discussion. Please try again.'));
} }
......
<script> <script>
import { mapGetters, mapState, mapActions } from 'vuex'; import { mapGetters, mapState, mapActions } from 'vuex';
import icon from '~/vue_shared/components/icon.vue';
import panelResizer from '~/vue_shared/components/panel_resizer.vue';
import repoCommitSection from './repo_commit_section.vue'; import repoCommitSection from './repo_commit_section.vue';
import icon from '../../vue_shared/components/icon.vue';
import panelResizer from '../../vue_shared/components/panel_resizer.vue';
export default { export default {
components: { components: {
......
<script> <script>
import icon from '~/vue_shared/components/icon.vue';
import repoTree from './ide_repo_tree.vue'; import repoTree from './ide_repo_tree.vue';
import icon from '../../vue_shared/components/icon.vue';
import newDropdown from './new_dropdown/index.vue'; import newDropdown from './new_dropdown/index.vue';
export default { export default {
......
<script> <script>
import projectAvatarImage from '~/vue_shared/components/project_avatar/image.vue';
import branchesTree from './ide_project_branches_tree.vue'; import branchesTree from './ide_project_branches_tree.vue';
import projectAvatarImage from '../../vue_shared/components/project_avatar/image.vue';
export default { export default {
components: { components: {
......
<script> <script>
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
import repoPreviousDirectory from './repo_prev_directory.vue'; import repoPreviousDirectory from './repo_prev_directory.vue';
import repoFile from './repo_file.vue'; import repoFile from './repo_file.vue';
import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue';
import { treeList } from '../stores/utils'; import { treeList } from '../stores/utils';
export default { export default {
......
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import icon from '~/vue_shared/components/icon.vue';
import panelResizer from '~/vue_shared/components/panel_resizer.vue';
import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
import projectTree from './ide_project_tree.vue'; import projectTree from './ide_project_tree.vue';
import icon from '../../vue_shared/components/icon.vue';
import panelResizer from '../../vue_shared/components/panel_resizer.vue';
import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue';
export default { export default {
components: { components: {
......
<script> <script>
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import icon from '../../vue_shared/components/icon.vue'; import icon from '~/vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import timeAgoMixin from '../../vue_shared/mixins/timeago'; import timeAgoMixin from '~/vue_shared/mixins/timeago';
export default { export default {
components: { components: {
......
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import flash, { hideFlash } from '../../flash'; import flash, { hideFlash } from '~/flash';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import loadingIcon from '~/vue_shared/components/loading_icon.vue';
export default { export default {
components: { components: {
......
<script> <script>
import { mapGetters, mapState, mapActions } from 'vuex'; import { mapGetters, mapState, mapActions } from 'vuex';
import tooltip from '../../vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import icon from '../../vue_shared/components/icon.vue'; import icon from '~/vue_shared/components/icon.vue';
import modal from '../../vue_shared/components/modal.vue'; import modal from '~/vue_shared/components/modal.vue';
import commitFilesList from './commit_sidebar/list.vue'; import commitFilesList from './commit_sidebar/list.vue';
export default { export default {
......
<script> <script>
import { mapGetters, mapActions, mapState } from 'vuex'; import { mapGetters, mapActions, mapState } from 'vuex';
import modal from '../../vue_shared/components/modal.vue'; import modal from '~/vue_shared/components/modal.vue';
export default { export default {
components: { components: {
......
<script> <script>
/* global monaco */ /* global monaco */
import { mapState, mapGetters, mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex';
import flash from '../../flash'; import flash from '~/flash';
import monacoLoader from '../monaco_loader'; import monacoLoader from '../monaco_loader';
import Editor from '../lib/editor'; import Editor from '../lib/editor';
......
<script> <script>
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue'; import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
export default { export default {
components: { components: {
......
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import LineHighlighter from '../../line_highlighter'; import LineHighlighter from '~/line_highlighter';
import syntaxHighlight from '../../syntax_highlight'; import syntaxHighlight from '~/syntax_highlight';
export default { export default {
computed: { computed: {
......
import Vue from 'vue'; import Vue from 'vue';
import { visitUrl } from '../../lib/utils/url_utility'; import { visitUrl } from '~/lib/utils/url_utility';
import flash from '../../flash'; import flash from '~/flash';
import service from '../services'; import service from '../services';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { stripHtml } from '../../lib/utils/text_utility'; import { stripHtml } from '../../lib/utils/text_utility';
......
...@@ -21,7 +21,7 @@ export default class LabelsSelect { ...@@ -21,7 +21,7 @@ export default class LabelsSelect {
} }
$els.each(function(i, dropdown) { $els.each(function(i, dropdown) {
var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, namespacePath, projectPath, saveLabelData, selectedLabel, showAny, showNo, $sidebarLabelTooltip, initialSelected, $toggleText, fieldName, useId, propertyName, showMenuAbove, $container, $dropdownContainer; var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelUrl, namespacePath, projectPath, saveLabelData, selectedLabel, showAny, showNo, $sidebarLabelTooltip, initialSelected, $toggleText, fieldName, useId, propertyName, showMenuAbove, $container, $dropdownContainer;
$dropdown = $(dropdown); $dropdown = $(dropdown);
$dropdownContainer = $dropdown.closest('.labels-filter'); $dropdownContainer = $dropdown.closest('.labels-filter');
$toggleText = $dropdown.find('.dropdown-toggle-text'); $toggleText = $dropdown.find('.dropdown-toggle-text');
...@@ -53,13 +53,6 @@ export default class LabelsSelect { ...@@ -53,13 +53,6 @@ export default class LabelsSelect {
.map(function () { .map(function () {
return this.value; return this.value;
}).get(); }).get();
if (issueUpdateURL != null) {
issueURLSplit = issueUpdateURL.split('/');
}
if (issueUpdateURL) {
labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %> <a href="<%- ["",issueURLSplit[1], issueURLSplit[2],""].join("/") %>issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;"> <%- label.title %> </span> </a> <% }); %>');
labelNoneHTMLTemplate = '<span class="no-value">None</span>';
}
const handleClick = options.handleClick; const handleClick = options.handleClick;
$sidebarLabelTooltip.tooltip(); $sidebarLabelTooltip.tooltip();
...@@ -91,14 +84,17 @@ export default class LabelsSelect { ...@@ -91,14 +84,17 @@ export default class LabelsSelect {
$loading.fadeOut(); $loading.fadeOut();
$dropdown.trigger('loaded.gl.dropdown'); $dropdown.trigger('loaded.gl.dropdown');
$selectbox.hide(); $selectbox.hide();
data.issueURLSplit = issueURLSplit; data.issueUpdateURL = issueUpdateURL;
labelCount = 0; labelCount = 0;
if (data.labels.length) { if (data.labels.length && issueUpdateURL) {
template = labelHTMLTemplate(data); template = LabelsSelect.getLabelTemplate({
labels: data.labels,
issueUpdateURL,
});
labelCount = data.labels.length; labelCount = data.labels.length;
} }
else { else {
template = labelNoneHTMLTemplate; template = '<span class="no-value">None</span>';
} }
$value.removeAttr('style').html(template); $value.removeAttr('style').html(template);
$sidebarCollapsedValue.text(labelCount); $sidebarCollapsedValue.text(labelCount);
...@@ -418,6 +414,26 @@ export default class LabelsSelect { ...@@ -418,6 +414,26 @@ export default class LabelsSelect {
this.bindEvents(); this.bindEvents();
} }
static getLabelTemplate(tplData) {
// We could use ES6 template string here
// and properly indent markup for readability
// but that also introduces unintended white-space
// so best approach is to use traditional way of
// concatenation
// see: http://2ality.com/2016/05/template-literal-whitespace.html#joining-arrays
const tpl = _.template([
'<% _.each(labels, function(label){ %>',
'<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>">',
'<span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;">',
'<%- label.title %>',
'</span>',
'</a>',
'<% }); %>',
].join(''));
return tpl(tplData);
}
bindEvents() { bindEvents() {
return $('body').on('change', '.selected_issue', this.onSelectCheckboxIssue); return $('body').on('change', '.selected_issue', this.onSelectCheckboxIssue);
} }
......
import jQuery from 'jquery';
import Cookies from 'js-cookie';
import axios from './axios_utils'; import axios from './axios_utils';
import { getLocationHash } from './url_utility'; import { getLocationHash } from './url_utility';
import { convertToCamelCase } from './text_utility'; import { convertToCamelCase } from './text_utility';
...@@ -22,13 +24,18 @@ export const getGroupSlug = () => { ...@@ -22,13 +24,18 @@ export const getGroupSlug = () => {
return null; return null;
}; };
export const isInIssuePage = () => { export const checkPageAndAction = (page, action) => {
const page = getPagePath(1); const pagePath = getPagePath(1);
const action = getPagePath(2); const actionPath = getPagePath(2);
return page === 'issues' && action === 'show'; return pagePath === page && actionPath === action;
}; };
export const isInIssuePage = () => checkPageAndAction('issues', 'show');
export const isInMRPage = () => checkPageAndAction('merge_requests', 'show');
export const isInNoteablePage = () => isInIssuePage() || isInMRPage();
export const hasVueMRDiscussionsCookie = () => Cookies.get('vue_mr_discussions');
export const ajaxGet = url => axios.get(url, { export const ajaxGet = url => axios.get(url, {
params: { format: 'js' }, params: { format: 'js' },
responseType: 'text', responseType: 'text',
...@@ -133,7 +140,11 @@ export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; ...@@ -133,7 +140,11 @@ export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
// 3) Middle-click or Mouse Wheel Click (e.which is 2) // 3) Middle-click or Mouse Wheel Click (e.which is 2)
export const isMetaClick = e => e.metaKey || e.ctrlKey || e.which === 2; export const isMetaClick = e => e.metaKey || e.ctrlKey || e.which === 2;
export const scrollToElement = ($el) => { export const scrollToElement = (element) => {
let $el = element;
if (!(element instanceof jQuery)) {
$el = $(element);
}
const top = $el.offset().top; const top = $el.offset().top;
const mrTabsHeight = $('.merge-request-tabs').height() || 0; const mrTabsHeight = $('.merge-request-tabs').height() || 0;
const headerHeight = $('.navbar-gitlab').height() || 0; const headerHeight = $('.navbar-gitlab').height() || 0;
......
...@@ -65,6 +65,20 @@ export function capitalizeFirstCharacter(text) { ...@@ -65,6 +65,20 @@ export function capitalizeFirstCharacter(text) {
return `${text[0].toUpperCase()}${text.slice(1)}`; return `${text[0].toUpperCase()}${text.slice(1)}`;
} }
export function camelCase(str) {
return str.replace(/_+([a-z])/gi, ($1, $2) => $2.toUpperCase());
}
export function camelCaseKeys(obj = {}) {
return Object.keys(obj).reduce((acc, key) => {
const camelKey = camelCase(key);
return {
...acc,
[camelKey]: obj[key],
};
}, {});
}
/** /**
* Replaces all html tags from a string with the given replacement. * Replaces all html tags from a string with the given replacement.
* *
......
...@@ -12,7 +12,7 @@ import './components/inline_conflict_lines'; ...@@ -12,7 +12,7 @@ import './components/inline_conflict_lines';
import './components/parallel_conflict_lines'; import './components/parallel_conflict_lines';
import syntaxHighlight from '../syntax_highlight'; import syntaxHighlight from '../syntax_highlight';
$(() => { export default function initMergeConflicts() {
const INTERACTIVE_RESOLVE_MODE = 'interactive'; const INTERACTIVE_RESOLVE_MODE = 'interactive';
const conflictsEl = document.querySelector('#conflicts'); const conflictsEl = document.querySelector('#conflicts');
const mergeConflictsStore = gl.mergeConflicts.mergeConflictsStore; const mergeConflictsStore = gl.mergeConflicts.mergeConflictsStore;
...@@ -91,4 +91,4 @@ $(() => { ...@@ -91,4 +91,4 @@ $(() => {
} }
} }
}); });
}); }
...@@ -241,6 +241,10 @@ export default class MergeRequestTabs { ...@@ -241,6 +241,10 @@ export default class MergeRequestTabs {
return newState; return newState;
} }
getCurrentAction() {
return this.currentAction;
}
loadCommits(source) { loadCommits(source) {
if (this.commitsLoaded) { if (this.commitsLoaded) {
return; return;
......
import Vue from 'vue';
import notesApp from '../notes/components/notes_app.vue';
import discussionCounter from '../notes/components/discussion_counter.vue';
import store from '../notes/stores';
document.addEventListener('DOMContentLoaded', () => {
new Vue({ // eslint-disable-line
el: '#js-vue-mr-discussions',
components: {
notesApp,
},
data() {
const notesDataset = document.getElementById('js-vue-mr-discussions').dataset;
return {
noteableData: JSON.parse(notesDataset.noteableData),
currentUserData: JSON.parse(notesDataset.currentUserData),
notesData: JSON.parse(notesDataset.notesData),
};
},
render(createElement) {
return createElement('notes-app', {
props: {
noteableData: this.noteableData,
notesData: this.notesData,
userData: this.currentUserData,
},
});
},
});
new Vue({ // eslint-disable-line
el: '#js-vue-discussion-counter',
components: {
discussionCounter,
},
store,
render(createElement) {
return createElement('discussion-counter');
},
});
});
...@@ -24,7 +24,7 @@ import GLForm from './gl_form'; ...@@ -24,7 +24,7 @@ import GLForm from './gl_form';
import loadAwardsHandler from './awards_handler'; import loadAwardsHandler from './awards_handler';
import Autosave from './autosave'; import Autosave from './autosave';
import TaskList from './task_list'; 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 imageDiffHelper from './image_diff/helpers/index';
import { localTimeAgo } from './lib/utils/datetime_utility'; import { localTimeAgo } from './lib/utils/datetime_utility';
...@@ -44,6 +44,10 @@ export default class Notes { ...@@ -44,6 +44,10 @@ export default class Notes {
} }
} }
static getInstance() {
return this.instance;
}
constructor(notes_url, note_ids, last_fetched_at, view, enableGFM = true) { constructor(notes_url, note_ids, last_fetched_at, view, enableGFM = true) {
this.updateTargetButtons = this.updateTargetButtons.bind(this); this.updateTargetButtons = this.updateTargetButtons.bind(this);
this.updateComment = this.updateComment.bind(this); this.updateComment = this.updateComment.bind(this);
...@@ -102,67 +106,77 @@ export default class Notes { ...@@ -102,67 +106,77 @@ export default class Notes {
} }
addBinding() { addBinding() {
this.$wrapperEl = hasVueMRDiscussionsCookie() ? $(document).find('.diffs') : $(document);
// Edit note link // Edit note link
$(document).on('click', '.js-note-edit', this.showEditForm.bind(this)); this.$wrapperEl.on('click', '.js-note-edit', this.showEditForm.bind(this));
$(document).on('click', '.note-edit-cancel', this.cancelEdit); this.$wrapperEl.on('click', '.note-edit-cancel', this.cancelEdit);
// Reopen and close actions for Issue/MR combined with note form submit // Reopen and close actions for Issue/MR combined with note form submit
$(document).on('click', '.js-comment-submit-button', this.postComment); this.$wrapperEl.on('click', '.js-comment-submit-button', this.postComment);
$(document).on('click', '.js-comment-save-button', this.updateComment); this.$wrapperEl.on('click', '.js-comment-save-button', this.updateComment);
$(document).on('keyup input', '.js-note-text', this.updateTargetButtons); this.$wrapperEl.on('keyup input', '.js-note-text', this.updateTargetButtons);
// resolve a discussion // 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) // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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 // 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. // 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); this.$wrapperEl.on('ajax:success', '.js-main-target-form', this.addNote);
$(document).on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote); this.$wrapperEl.on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote);
$(document).on('ajax:success', '.js-main-target-form', this.resetMainTargetForm); this.$wrapperEl.on('ajax:success', '.js-main-target-form', this.resetMainTargetForm);
$(document).on('ajax:complete', '.js-main-target-form', this.reenableTargetFormSubmitButton); this.$wrapperEl.on('ajax:complete', '.js-main-target-form', this.reenableTargetFormSubmitButton);
// when a key is clicked on the notes // 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` // 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() { cleanBinding() {
$(document).off('click', '.js-note-edit'); if (!this.eventsBound) {
$(document).off('click', '.note-edit-cancel'); return;
$(document).off('click', '.js-note-delete'); }
$(document).off('click', '.js-note-attachment-delete');
$(document).off('click', '.js-discussion-reply-button'); this.$wrapperEl.off('click', '.js-note-edit');
$(document).off('click', '.js-add-diff-note-button'); this.$wrapperEl.off('click', '.note-edit-cancel');
$(document).off('click', '.js-add-image-diff-note-button'); this.$wrapperEl.off('click', '.js-note-delete');
$(document).off('visibilitychange'); this.$wrapperEl.off('click', '.js-note-attachment-delete');
$(document).off('keyup input', '.js-note-text'); this.$wrapperEl.off('click', '.js-discussion-reply-button');
$(document).off('click', '.js-note-target-reopen'); this.$wrapperEl.off('click', '.js-add-diff-note-button');
$(document).off('click', '.js-note-target-close'); this.$wrapperEl.off('click', '.js-add-image-diff-note-button');
$(document).off('click', '.js-note-discard'); this.$wrapperEl.off('visibilitychange');
$(document).off('keydown', '.js-note-text'); this.$wrapperEl.off('keyup input', '.js-note-text');
$(document).off('click', '.js-comment-resolve-button'); this.$wrapperEl.off('click', '.js-note-target-reopen');
$(document).off('click', '.system-note-commit-list-toggler'); this.$wrapperEl.off('click', '.js-note-target-close');
$(document).off('ajax:success', '.js-main-target-form'); this.$wrapperEl.off('click', '.js-note-discard');
$(document).off('ajax:success', '.js-discussion-note-form'); this.$wrapperEl.off('keydown', '.js-note-text');
$(document).off('ajax:complete', '.js-main-target-form'); 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); $(window).off('hashchange', this.onHashChange);
} }
...@@ -252,8 +266,10 @@ export default class Notes { ...@@ -252,8 +266,10 @@ export default class Notes {
if (this.refreshing) { if (this.refreshing) {
return; return;
} }
this.refreshing = true; this.refreshing = true;
axios.get(this.notes_url, {
axios.get(`${this.notes_url}?html=true`, {
headers: { headers: {
'X-Last-Fetched-At': this.last_fetched_at, 'X-Last-Fetched-At': this.last_fetched_at,
}, },
...@@ -350,7 +366,7 @@ export default class Notes { ...@@ -350,7 +366,7 @@ export default class Notes {
} }
if (!noteEntity.valid) { if (!noteEntity.valid) {
if (noteEntity.errors.commands_only) { if (noteEntity.errors && noteEntity.errors.commands_only) {
if (noteEntity.commands_changes && if (noteEntity.commands_changes &&
Object.keys(noteEntity.commands_changes).length > 0) { Object.keys(noteEntity.commands_changes).length > 0) {
$notesList.find('.system-note.being-posted').remove(); $notesList.find('.system-note.being-posted').remove();
...@@ -363,6 +379,10 @@ export default class Notes { ...@@ -363,6 +379,10 @@ export default class Notes {
const $note = $notesList.find(`#note_${noteEntity.id}`); const $note = $notesList.find(`#note_${noteEntity.id}`);
if (Notes.isNewNote(noteEntity, this.note_ids)) { if (Notes.isNewNote(noteEntity, this.note_ids)) {
if (hasVueMRDiscussionsCookie()) {
return;
}
this.note_ids.push(noteEntity.id); this.note_ids.push(noteEntity.id);
if ($notesList.length) { if ($notesList.length) {
...@@ -399,6 +419,8 @@ export default class Notes { ...@@ -399,6 +419,8 @@ export default class Notes {
this.setupNewNote($updatedNote); this.setupNewNote($updatedNote);
} }
} }
Notes.refreshVueNotes();
} }
isParallelView() { isParallelView() {
...@@ -406,12 +428,11 @@ export default class Notes { ...@@ -406,12 +428,11 @@ export default class Notes {
} }
/** /**
* Render note in discussion area. * Render note in discussion area. To render inline notes use renderDiscussionNote.
*
* Note: for rendering inline notes use renderDiscussionNote
*/ */
renderDiscussionNote(noteEntity, $form) { renderDiscussionNote(noteEntity, $form) {
var discussionContainer, form, row, lineType, diffAvatarContainer; var discussionContainer, form, row, lineType, diffAvatarContainer;
if (!Notes.isNewNote(noteEntity, this.note_ids)) { if (!Notes.isNewNote(noteEntity, this.note_ids)) {
return; return;
} }
...@@ -452,7 +473,9 @@ export default class Notes { ...@@ -452,7 +473,9 @@ export default class Notes {
// Init discussion on 'Discussion' page if it is merge request page // Init discussion on 'Discussion' page if it is merge request page
const page = $('body').attr('data-page'); const page = $('body').attr('data-page');
if ((page && page.indexOf('projects:merge_request') !== -1) || !noteEntity.diff_discussion_html) { 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 { } else {
// append new note to all matching discussions // append new note to all matching discussions
...@@ -634,7 +657,6 @@ export default class Notes { ...@@ -634,7 +657,6 @@ export default class Notes {
var $noteEntityEl, $note_li; var $noteEntityEl, $note_li;
// Convert returned HTML to a jQuery object so we can modify it further // Convert returned HTML to a jQuery object so we can modify it further
$noteEntityEl = $(noteEntity.html); $noteEntityEl = $(noteEntity.html);
$noteEntityEl.addClass('fade-in-full');
this.revertNoteEditForm($targetNote); this.revertNoteEditForm($targetNote);
$noteEntityEl.renderGFM(); $noteEntityEl.renderGFM();
// Find the note's `li` element by ID and replace it with the updated HTML // Find the note's `li` element by ID and replace it with the updated HTML
...@@ -730,7 +752,7 @@ export default class Notes { ...@@ -730,7 +752,7 @@ export default class Notes {
var selector = this.getEditFormSelector($target); var selector = this.getEditFormSelector($target);
var $editForm = $(selector); var $editForm = $(selector);
$editForm.insertBefore('.notes-form'); $editForm.insertBefore('.diffs');
$editForm.find('.js-comment-save-button').enable(); $editForm.find('.js-comment-save-button').enable();
$editForm.find('.js-finish-edit-warning').hide(); $editForm.find('.js-finish-edit-warning').hide();
} }
...@@ -746,7 +768,8 @@ export default class Notes { ...@@ -746,7 +768,8 @@ export default class Notes {
} }
removeNoteEditForm($note) { removeNoteEditForm($note) {
var form = $note.find('.current-note-edit-form'); var form = $note.find('.diffs .current-note-edit-form');
$note.removeClass('is-editing'); $note.removeClass('is-editing');
form.removeClass('current-note-edit-form'); form.removeClass('current-note-edit-form');
form.find('.js-finish-edit-warning').hide(); form.find('.js-finish-edit-warning').hide();
...@@ -818,6 +841,7 @@ export default class Notes { ...@@ -818,6 +841,7 @@ export default class Notes {
}; };
})(this)); })(this));
Notes.refreshVueNotes();
Notes.checkMergeRequestStatus(); Notes.checkMergeRequestStatus();
return this.updateNotesCount(-1); return this.updateNotesCount(-1);
} }
...@@ -1157,7 +1181,7 @@ export default class Notes { ...@@ -1157,7 +1181,7 @@ export default class Notes {
this.glForm = new GLForm($editForm.find('form'), this.enableGFM); this.glForm = new GLForm($editForm.find('form'), this.enableGFM);
$editForm.find('form') $editForm.find('form')
.attr('action', postUrl) .attr('action', `${postUrl}?html=true`)
.attr('data-remote', 'true'); .attr('data-remote', 'true');
$editForm.find('.js-form-target-id').val(targetId); $editForm.find('.js-form-target-id').val(targetId);
$editForm.find('.js-form-target-type').val(targetType); $editForm.find('.js-form-target-type').val(targetType);
...@@ -1280,6 +1304,10 @@ export default class Notes { ...@@ -1280,6 +1304,10 @@ export default class Notes {
return $updatedNote; return $updatedNote;
} }
static refreshVueNotes() {
document.dispatchEvent(new CustomEvent('refreshVueNotes'));
}
/** /**
* Get data from Form attributes to use for saving/submitting comment. * Get data from Form attributes to use for saving/submitting comment.
*/ */
...@@ -1481,7 +1509,7 @@ export default class Notes { ...@@ -1481,7 +1509,7 @@ export default class Notes {
/* eslint-disable promise/catch-or-return */ /* eslint-disable promise/catch-or-return */
// Make request to submit comment on server // Make request to submit comment on server
axios.post(formAction, formData) axios.post(`${formAction}?html=true`, formData)
.then((res) => { .then((res) => {
const note = res.data; const note = res.data;
...@@ -1546,6 +1574,8 @@ export default class Notes { ...@@ -1546,6 +1574,8 @@ export default class Notes {
if ($notesContainer.length) { if ($notesContainer.length) {
$notesContainer.append('<div class="flash-container" style="display: none;"></div>'); $notesContainer.append('<div class="flash-container" style="display: none;"></div>');
} }
Notes.refreshVueNotes();
} else if (isMainForm) { // Check if this was main thread comment } else if (isMainForm) { // Check if this was main thread comment
// Show final note element on UI and perform form and action buttons cleanup // Show final note element on UI and perform form and action buttons cleanup
this.addNote($form, note); this.addNote($form, note);
...@@ -1627,7 +1657,7 @@ export default class Notes { ...@@ -1627,7 +1657,7 @@ export default class Notes {
/* eslint-disable promise/catch-or-return */ /* eslint-disable promise/catch-or-return */
// Make request to update comment on server // Make request to update comment on server
axios.post(formAction, formData) axios.post(`${formAction}?html=true`, formData)
.then(({ data }) => { .then(({ data }) => {
// Submission successful! render final note element // Submission successful! render final note element
this.updateNote(data, $editingNote); this.updateNote(data, $editingNote);
......
...@@ -2,10 +2,11 @@ ...@@ -2,10 +2,11 @@
import { mapActions, mapGetters } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import _ from 'underscore'; import _ from 'underscore';
import Autosize from 'autosize'; import Autosize from 'autosize';
import { __ } from '~/locale'; import { __, sprintf } from '~/locale';
import Flash from '../../flash'; import Flash from '../../flash';
import Autosave from '../../autosave'; import Autosave from '../../autosave';
import TaskList from '../../task_list'; import TaskList from '../../task_list';
import { capitalizeFirstCharacter, convertToCamelCase } from '../../lib/utils/text_utility';
import * as constants from '../constants'; import * as constants from '../constants';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
...@@ -29,6 +30,12 @@ ...@@ -29,6 +30,12 @@
mixins: [ mixins: [
issuableStateMixin, issuableStateMixin,
], ],
props: {
noteableType: {
type: String,
required: true,
},
},
data() { data() {
return { return {
note: '', note: '',
...@@ -43,37 +50,51 @@ ...@@ -43,37 +50,51 @@
'getUserData', 'getUserData',
'getNoteableData', 'getNoteableData',
'getNotesData', 'getNotesData',
'issueState', 'openState',
]), ]),
noteableDisplayName() {
return this.noteableType.replace(/_/g, ' ');
},
isLoggedIn() { isLoggedIn() {
return this.getUserData.id; return this.getUserData.id;
}, },
commentButtonTitle() { commentButtonTitle() {
return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion'; return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion';
}, },
isIssueOpen() { isOpen() {
return this.issueState === constants.OPENED || this.issueState === constants.REOPENED; return this.openState === constants.OPENED || this.openState === constants.REOPENED;
}, },
canCreateNote() { canCreateNote() {
return this.getNoteableData.current_user.can_create_note; return this.getNoteableData.current_user.can_create_note;
}, },
issueActionButtonTitle() { issueActionButtonTitle() {
if (this.note.length) { const openOrClose = this.isOpen ? 'close' : 'reopen';
const actionText = this.isIssueOpen ? 'close' : 'reopen';
return this.noteType === constants.COMMENT ? if (this.note.length) {
`Comment & ${actionText} issue` : return sprintf(
`Start discussion & ${actionText} issue`; __('%{actionText} & %{openOrClose} %{noteable}'),
{
actionText: this.commentButtonTitle,
openOrClose,
noteable: this.noteableDisplayName,
},
);
} }
return this.isIssueOpen ? 'Close issue' : 'Reopen issue'; return sprintf(
__('%{openOrClose} %{noteable}'),
{
openOrClose: capitalizeFirstCharacter(openOrClose),
noteable: this.noteableDisplayName,
},
);
}, },
actionButtonClassNames() { actionButtonClassNames() {
return { return {
'btn-reopen': !this.isIssueOpen, 'btn-reopen': !this.isOpen,
'btn-close': this.isIssueOpen, 'btn-close': this.isOpen,
'js-note-target-close': this.isIssueOpen, 'js-note-target-close': this.isOpen,
'js-note-target-reopen': !this.isIssueOpen, 'js-note-target-reopen': !this.isOpen,
}; };
}, },
markdownDocsPath() { markdownDocsPath() {
...@@ -138,7 +159,7 @@ ...@@ -138,7 +159,7 @@
flashContainer: this.$el, flashContainer: this.$el,
data: { data: {
note: { note: {
noteable_type: constants.NOTEABLE_TYPE, noteable_type: this.noteableType,
noteable_id: this.getNoteableData.id, noteable_id: this.getNoteableData.id,
note: this.note, note: this.note,
}, },
...@@ -193,19 +214,29 @@ Please check your network connection and try again.`; ...@@ -193,19 +214,29 @@ Please check your network connection and try again.`;
this.isSubmitting = false; this.isSubmitting = false;
}, },
toggleIssueState() { toggleIssueState() {
if (this.isIssueOpen) { if (this.isOpen) {
this.closeIssue() this.closeIssue()
.then(() => this.enableButton()) .then(() => this.enableButton())
.catch(() => { .catch(() => {
this.enableButton(); this.enableButton();
Flash(__('Something went wrong while closing the issue. Please try again later')); Flash(
sprintf(
__('Something went wrong while closing the %{issuable}. Please try again later'),
{ issuable: this.noteableDisplayName },
),
);
}); });
} else { } else {
this.reopenIssue() this.reopenIssue()
.then(() => this.enableButton()) .then(() => this.enableButton())
.catch(() => { .catch(() => {
this.enableButton(); this.enableButton();
Flash(__('Something went wrong while reopening the issue. Please try again later')); Flash(
sprintf(
__('Something went wrong while reopening the %{issuable}. Please try again later'),
{ issuable: this.noteableDisplayName },
),
);
}); });
} }
}, },
...@@ -221,7 +252,6 @@ Please check your network connection and try again.`; ...@@ -221,7 +252,6 @@ Please check your network connection and try again.`;
this.$refs.markdownField.previewMarkdown = false; this.$refs.markdownField.previewMarkdown = false;
} }
// reset autostave
this.autosave.reset(); this.autosave.reset();
}, },
setNoteType(type) { setNoteType(type) {
...@@ -240,10 +270,11 @@ Please check your network connection and try again.`; ...@@ -240,10 +270,11 @@ Please check your network connection and try again.`;
}, },
initAutoSave() { initAutoSave() {
if (this.isLoggedIn) { if (this.isLoggedIn) {
const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType));
this.autosave = new Autosave( this.autosave = new Autosave(
$(this.$refs.textarea), $(this.$refs.textarea),
['Note', 'Issue', this.getNoteableData.id], ['Note', noteableType, this.getNoteableData.id],
'issue',
); );
} }
}, },
...@@ -331,7 +362,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" ...@@ -331,7 +362,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
:disabled="isSubmitButtonDisabled" :disabled="isSubmitButtonDisabled"
class="btn btn-create comment-btn js-comment-button js-comment-submit-button" class="btn btn-create comment-btn js-comment-button js-comment-submit-button"
type="submit"> type="submit">
{{ commentButtonTitle }} {{ __(commentButtonTitle) }}
</button> </button>
<button <button
:disabled="isSubmitButtonDisabled" :disabled="isSubmitButtonDisabled"
...@@ -359,7 +390,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" ...@@ -359,7 +390,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
<div class="description"> <div class="description">
<strong>Comment</strong> <strong>Comment</strong>
<p> <p>
Add a general comment to this issue. Add a general comment to this {{ noteableDisplayName }}.
</p> </p>
</div> </div>
</button> </button>
......
<script>
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
ClipboardButton,
Icon,
},
props: {
diffFile: {
type: Object,
required: true,
},
},
computed: {
titleTag() {
return this.diffFile.discussionPath ? 'a' : 'span';
},
},
};
</script>
<template>
<div class="file-header-content">
<div
v-if="diffFile.submodule"
>
<span>
<icon name="archive" />
<strong
v-html="diffFile.submoduleLink"
class="file-title-name"
></strong>
<clipboard-button
title="Copy file path to clipboard"
:text="diffFile.submoduleLink"
/>
</span>
</div>
<template v-else>
<component
ref="titleWrapper"
:is="titleTag"
:href="diffFile.discussionPath"
>
<span v-html="diffFile.blobIcon"></span>
<span v-if="diffFile.renamedFile">
<strong
class="file-title-name has-tooltip"
:title="diffFile.oldPath"
data-container="body"
>
{{ diffFile.oldPath }}
</strong>
&rarr;
<strong
class="file-title-name has-tooltip"
:title="diffFile.newPath"
data-container="body"
>
{{ diffFile.newPath }}
</strong>
</span>
<strong
v-else
class="file-title-name has-tooltip"
:title="diffFile.oldPath"
data-container="body"
>
{{ diffFile.filePath }}
<span v-if="diffFile.deletedFile">
deleted
</span>
</strong>
</component>
<clipboard-button
title="Copy file path to clipboard"
:text="diffFile.filePath"
/>
<small
v-if="diffFile.modeChanged"
ref="fileMode"
>
{{ diffFile.aMode }}{{ diffFile.bMode }}
</small>
</template>
</div>
</template>
<script>
import syntaxHighlight from '~/syntax_highlight';
import imageDiffHelper from '~/image_diff/helpers/index';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import DiffFileHeader from './diff_file_header.vue';
export default {
components: {
DiffFileHeader,
},
props: {
discussion: {
type: Object,
required: true,
},
},
computed: {
isImageDiff() {
return !this.diffFile.text;
},
diffFileClass() {
const { text } = this.diffFile;
return text ? 'text-file' : 'js-image-file';
},
diffRows() {
return $(this.discussion.truncatedDiffLines);
},
diffFile() {
return convertObjectPropsToCamelCase(this.discussion.diffFile);
},
imageDiffHtml() {
return this.discussion.imageDiffHtml;
},
},
mounted() {
if (this.isImageDiff) {
const canCreateNote = false;
const renderCommentBadge = true;
imageDiffHelper.initImageDiff(this.$refs.fileHolder, canCreateNote, renderCommentBadge);
} else {
const fileHolder = $(this.$refs.fileHolder);
this.$nextTick(() => {
syntaxHighlight(fileHolder);
});
}
},
methods: {
rowTag(html) {
return html.outerHTML ? 'tr' : 'template';
},
},
};
</script>
<template>
<div
ref="fileHolder"
class="diff-file file-holder"
:class="diffFileClass"
>
<div class="js-file-title file-title file-title-flex-parent">
<diff-file-header
:diff-file="diffFile"
/>
</div>
<div
v-if="diffFile.text"
class="diff-content code js-syntax-highlight"
>
<table>
<component
:is="rowTag(html)"
:class="html.className"
v-for="(html, index) in diffRows"
v-html="html.outerHTML"
:key="index"
/>
<tr class="notes_holder">
<td
class="notes_line"
colspan="2"
></td>
<td class="notes_content">
<slot></slot>
</td>
</tr>
</table>
</div>
<div
v-else
>
<div v-html="imageDiffHtml"></div>
<slot></slot>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import resolveSvg from 'icons/_icon_resolve_discussion.svg';
import resolvedSvg from 'icons/_icon_status_success_solid.svg';
import mrIssueSvg from 'icons/_icon_mr_issue.svg';
import nextDiscussionSvg from 'icons/_next_discussion.svg';
import { pluralize } from '../../lib/utils/text_utility';
import { scrollToElement } from '../../lib/utils/common_utils';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
directives: {
tooltip,
},
computed: {
...mapGetters([
'getUserData',
'getNoteableData',
'discussionCount',
'unresolvedDiscussions',
'resolvedDiscussionCount',
]),
isLoggedIn() {
return this.getUserData.id;
},
hasNextButton() {
return this.isLoggedIn && !this.allResolved;
},
countText() {
return pluralize('discussion', this.discussionCount);
},
allResolved() {
return this.resolvedDiscussionCount === this.discussionCount;
},
resolveAllDiscussionsIssuePath() {
return this.getNoteableData.create_issue_to_resolve_discussions_path;
},
firstUnresolvedDiscussionId() {
const item = this.unresolvedDiscussions[0] || {};
return item.id;
},
},
created() {
this.resolveSvg = resolveSvg;
this.resolvedSvg = resolvedSvg;
this.mrIssueSvg = mrIssueSvg;
this.nextDiscussionSvg = nextDiscussionSvg;
},
methods: {
jumpToFirstDiscussion() {
const el = document.querySelector(`[data-discussion-id="${this.firstUnresolvedDiscussionId}"]`);
const activeTab = window.mrTabs.currentAction;
if (activeTab === 'commits' || activeTab === 'pipelines') {
window.mrTabs.activateTab('show');
}
if (el) {
scrollToElement(el);
}
},
},
};
</script>
<template>
<div class="line-resolve-all-container prepend-top-10">
<div>
<div
v-if="discussionCount > 0"
:class="{ 'has-next-btn': hasNextButton }"
class="line-resolve-all">
<span
:class="{ 'is-active': allResolved }"
class="line-resolve-btn is-disabled"
type="button">
<span
v-if="allResolved"
v-html="resolvedSvg"
></span>
<span
v-else
v-html="resolveSvg"
></span>
</span>
<span class=".line-resolve-text">
{{ resolvedDiscussionCount }}/{{ discussionCount }} {{ countText }} resolved
</span>
</div>
<div
v-if="resolveAllDiscussionsIssuePath && !allResolved"
class="btn-group"
role="group">
<a
:href="resolveAllDiscussionsIssuePath"
v-tooltip
title="Resolve all discussions in new issue"
data-container="body"
class="new-issue-for-discussion btn btn-default discussion-create-issue-btn">
<span v-html="mrIssueSvg"></span>
</a>
</div>
<div
v-if="isLoggedIn && !allResolved"
class="btn-group"
role="group">
<button
@click="jumpToFirstDiscussion"
v-tooltip
title="Jump to first unresolved discussion"
data-container="body"
class="btn btn-default discussion-next-btn">
<span v-html="nextDiscussionSvg"></span>
</button>
</div>
</div>
</div>
</template>
...@@ -4,6 +4,8 @@ ...@@ -4,6 +4,8 @@
import emojiSmile from 'icons/_emoji_smile.svg'; import emojiSmile from 'icons/_emoji_smile.svg';
import emojiSmiley from 'icons/_emoji_smiley.svg'; import emojiSmiley from 'icons/_emoji_smiley.svg';
import editSvg from 'icons/_icon_pencil.svg'; import editSvg from 'icons/_icon_pencil.svg';
import resolveDiscussionSvg from 'icons/_icon_resolve_discussion.svg';
import resolvedDiscussionSvg from 'icons/_icon_status_success_solid.svg';
import ellipsisSvg from 'icons/_ellipsis_v.svg'; import ellipsisSvg from 'icons/_ellipsis_v.svg';
import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
...@@ -42,6 +44,26 @@ ...@@ -42,6 +44,26 @@
type: Boolean, type: Boolean,
required: true, required: true,
}, },
resolvable: {
type: Boolean,
required: false,
default: false,
},
isResolved: {
type: Boolean,
required: false,
default: false,
},
isResolving: {
type: Boolean,
required: false,
default: false,
},
resolvedBy: {
type: Object,
required: false,
default: () => ({}),
},
canReportAsAbuse: { canReportAsAbuse: {
type: Boolean, type: Boolean,
required: true, required: true,
...@@ -63,6 +85,15 @@ ...@@ -63,6 +85,15 @@
currentUserId() { currentUserId() {
return this.getUserDataByProp('id'); return this.getUserDataByProp('id');
}, },
resolveButtonTitle() {
let title = 'Mark as resolved';
if (this.resolvedBy) {
title = `Resolved by ${this.resolvedBy.name}`;
}
return title;
},
}, },
created() { created() {
this.emojiSmiling = emojiSmiling; this.emojiSmiling = emojiSmiling;
...@@ -70,6 +101,8 @@ ...@@ -70,6 +101,8 @@
this.emojiSmiley = emojiSmiley; this.emojiSmiley = emojiSmiley;
this.editSvg = editSvg; this.editSvg = editSvg;
this.ellipsisSvg = ellipsisSvg; this.ellipsisSvg = ellipsisSvg;
this.resolveDiscussionSvg = resolveDiscussionSvg;
this.resolvedDiscussionSvg = resolvedDiscussionSvg;
}, },
methods: { methods: {
onEdit() { onEdit() {
...@@ -78,6 +111,9 @@ ...@@ -78,6 +111,9 @@
onDelete() { onDelete() {
this.$emit('handleDelete'); this.$emit('handleDelete');
}, },
onResolve() {
this.$emit('handleResolve');
},
}, },
}; };
</script> </script>
...@@ -89,6 +125,31 @@ ...@@ -89,6 +125,31 @@
class="note-role user-access-role"> class="note-role user-access-role">
{{ accessLevel }} {{ accessLevel }}
</span> </span>
<div
v-if="resolvable"
class="note-actions-item">
<button
v-tooltip
@click="onResolve"
:class="{ 'is-disabled': !resolvable, 'is-active': isResolved }"
:title="resolveButtonTitle"
:aria-label="resolveButtonTitle"
type="button"
class="line-resolve-btn note-action-button">
<template v-if="!isResolving">
<div
v-if="isResolved"
v-html="resolvedDiscussionSvg"></div>
<div
v-else
v-html="resolveDiscussionSvg"></div>
</template>
<loading-icon
v-else
:inline="true"
/>
</button>
</div>
<div <div
v-if="canAddAwardEmoji" v-if="canAddAwardEmoji"
class="note-actions-item"> class="note-actions-item">
......
...@@ -41,7 +41,7 @@ ...@@ -41,7 +41,7 @@
this.initTaskList(); this.initTaskList();
if (this.isEditing) { if (this.isEditing) {
this.initAutoSave(); this.initAutoSave(this.note.noteable_type);
} }
}, },
updated() { updated() {
...@@ -50,7 +50,7 @@ ...@@ -50,7 +50,7 @@
if (this.isEditing) { if (this.isEditing) {
if (!this.autosave) { if (!this.autosave) {
this.initAutoSave(); this.initAutoSave(this.note.noteable_type);
} else { } else {
this.setAutoSave(); this.setAutoSave();
} }
......
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
import markdownField from '../../vue_shared/components/markdown/field.vue'; import markdownField from '../../vue_shared/components/markdown/field.vue';
import issuableStateMixin from '../mixins/issuable_state'; import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
export default { export default {
name: 'IssueNoteForm', name: 'IssueNoteForm',
...@@ -13,6 +14,7 @@ ...@@ -13,6 +14,7 @@
}, },
mixins: [ mixins: [
issuableStateMixin, issuableStateMixin,
resolvable,
], ],
props: { props: {
noteBody: { noteBody: {
...@@ -30,7 +32,7 @@ ...@@ -30,7 +32,7 @@
required: false, required: false,
default: 'Save comment', default: 'Save comment',
}, },
discussion: { note: {
type: Object, type: Object,
required: false, required: false,
default: () => ({}), default: () => ({}),
...@@ -42,9 +44,11 @@ ...@@ -42,9 +44,11 @@
}, },
data() { data() {
return { return {
note: this.noteBody, updatedNoteBody: this.noteBody,
conflictWhileEditing: false, conflictWhileEditing: false,
isSubmitting: false, isSubmitting: false,
isResolving: false,
resolveAsThread: true,
}; };
}, },
computed: { computed: {
...@@ -71,13 +75,13 @@ ...@@ -71,13 +75,13 @@
return this.getUserDataByProp('id'); return this.getUserDataByProp('id');
}, },
isDisabled() { isDisabled() {
return !this.note.length || this.isSubmitting; return !this.updatedNoteBody.length || this.isSubmitting;
}, },
}, },
watch: { watch: {
noteBody() { noteBody() {
if (this.note === this.noteBody) { if (this.updatedNoteBody === this.noteBody) {
this.note = this.noteBody; this.updatedNoteBody = this.noteBody;
} else { } else {
this.conflictWhileEditing = true; this.conflictWhileEditing = true;
} }
...@@ -87,16 +91,24 @@ ...@@ -87,16 +91,24 @@
this.$refs.textarea.focus(); this.$refs.textarea.focus();
}, },
methods: { methods: {
handleUpdate() { ...mapActions([
'toggleResolveNote',
]),
handleUpdate(shouldResolve) {
const beforeSubmitDiscussionState = this.discussionResolved;
this.isSubmitting = true; this.isSubmitting = true;
this.$emit('handleFormUpdate', this.note, this.$refs.editNoteForm, () => { this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => {
this.isSubmitting = false; this.isSubmitting = false;
if (shouldResolve) {
this.resolveHandler(beforeSubmitDiscussionState);
}
}); });
}, },
editMyLastNote() { editMyLastNote() {
if (this.note === '') { if (this.updatedNoteBody === '') {
const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion); const lastNoteInDiscussion = this.getDiscussionLastNote(this.updatedNoteBody);
if (lastNoteInDiscussion) { if (lastNoteInDiscussion) {
eventHub.$emit('enterEditMode', { eventHub.$emit('enterEditMode', {
...@@ -107,7 +119,7 @@ ...@@ -107,7 +119,7 @@
}, },
cancelHandler(shouldConfirm = false) { cancelHandler(shouldConfirm = false) {
// Sends information about confirm message and if the textarea has changed // Sends information about confirm message and if the textarea has changed
this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.note); this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.updatedNoteBody);
}, },
}, },
}; };
...@@ -150,7 +162,7 @@ ...@@ -150,7 +162,7 @@
js-autosize markdown-area js-vue-issue-note-form js-vue-textarea" js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
:data-supports-quick-actions="!isEditing" :data-supports-quick-actions="!isEditing"
aria-label="Description" aria-label="Description"
v-model="note" v-model="updatedNoteBody"
ref="textarea" ref="textarea"
slot="textarea" slot="textarea"
placeholder="Write a comment or drag your files here..." placeholder="Write a comment or drag your files here..."
...@@ -168,6 +180,13 @@ js-autosize markdown-area js-vue-issue-note-form js-vue-textarea" ...@@ -168,6 +180,13 @@ js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
class="js-vue-issue-save btn btn-save"> class="js-vue-issue-save btn btn-save">
{{ saveButtonTitle }} {{ saveButtonTitle }}
</button> </button>
<button
v-if="note.resolvable"
@click.prevent="handleUpdate(true)"
class="btn btn-nr btn-default append-right-10 js-comment-resolve-button"
>
{{ resolveButtonTitle }}
</button>
<button <button
@click="cancelHandler()" @click="cancelHandler()"
class="btn btn-cancel note-edit-cancel" class="btn btn-cancel note-edit-cancel"
......
...@@ -34,15 +34,15 @@ ...@@ -34,15 +34,15 @@
required: false, required: false,
default: false, default: false,
}, },
}, expanded: {
data() { type: Boolean,
return { required: false,
isExpanded: true, default: true,
}; },
}, },
computed: { computed: {
toggleChevronClass() { toggleChevronClass() {
return this.isExpanded ? 'fa-chevron-up' : 'fa-chevron-down'; return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down';
}, },
noteTimestampLink() { noteTimestampLink() {
return `#note_${this.noteId}`; return `#note_${this.noteId}`;
...@@ -53,7 +53,6 @@ ...@@ -53,7 +53,6 @@
'setTargetNoteHash', 'setTargetNoteHash',
]), ]),
handleToggle() { handleToggle() {
this.isExpanded = !this.isExpanded;
this.$emit('toggleHandler'); this.$emit('toggleHandler');
}, },
updateTargetNoteHash() { updateTargetNoteHash() {
......
<script> <script>
import { mapActions, mapGetters } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import resolveDiscussionsSvg from 'icons/_icon_mr_issue.svg';
import nextDiscussionsSvg from 'icons/_next_discussion.svg';
import Flash from '../../flash'; import Flash from '../../flash';
import { SYSTEM_NOTE } from '../constants'; import { SYSTEM_NOTE } from '../constants';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
...@@ -8,13 +10,19 @@ ...@@ -8,13 +10,19 @@
import noteSignedOutWidget from './note_signed_out_widget.vue'; import noteSignedOutWidget from './note_signed_out_widget.vue';
import noteEditedText from './note_edited_text.vue'; import noteEditedText from './note_edited_text.vue';
import noteForm from './note_form.vue'; import noteForm from './note_form.vue';
import diffWithNote from './diff_with_note.vue';
import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
import autosave from '../mixins/autosave'; import autosave from '../mixins/autosave';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
import tooltip from '../../vue_shared/directives/tooltip';
import { scrollToElement } from '../../lib/utils/common_utils';
export default { export default {
components: { components: {
noteableNote, noteableNote,
diffWithNote,
userAvatarLink, userAvatarLink,
noteHeader, noteHeader,
noteSignedOutWidget, noteSignedOutWidget,
...@@ -23,8 +31,13 @@ ...@@ -23,8 +31,13 @@
placeholderNote, placeholderNote,
placeholderSystemNote, placeholderSystemNote,
}, },
directives: {
tooltip,
},
mixins: [ mixins: [
autosave, autosave,
noteable,
resolvable,
], ],
props: { props: {
note: { note: {
...@@ -35,14 +48,25 @@ ...@@ -35,14 +48,25 @@
data() { data() {
return { return {
isReplying: false, isReplying: false,
isResolving: false,
resolveAsThread: true,
}; };
}, },
computed: { computed: {
...mapGetters([ ...mapGetters([
'getNoteableData', 'getNoteableData',
'discussionCount',
'resolvedDiscussionCount',
'unresolvedDiscussions',
]), ]),
discussion() { discussion() {
return this.note.notes[0]; return {
...this.note.notes[0],
truncatedDiffLines: this.note.truncated_diff_lines,
diffFile: this.note.diff_file,
diffDiscussion: this.note.diff_discussion,
imageDiffHtml: this.note.image_diff_html,
};
}, },
author() { author() {
return this.discussion.author; return this.discussion.author;
...@@ -71,26 +95,40 @@ ...@@ -71,26 +95,40 @@
return null; return null;
}, },
hasUnresolvedDiscussion() {
return this.unresolvedDiscussions.length > 0;
},
wrapperComponent() {
return (this.discussion.diffDiscussion && this.discussion.diffFile) ? diffWithNote : 'div';
},
wrapperClass() {
return this.isDiffDiscussion ? '' : 'panel panel-default';
},
}, },
mounted() { mounted() {
if (this.isReplying) { if (this.isReplying) {
this.initAutoSave(); this.initAutoSave(this.discussion.noteable_type);
} }
}, },
updated() { updated() {
if (this.isReplying) { if (this.isReplying) {
if (!this.autosave) { if (!this.autosave) {
this.initAutoSave(); this.initAutoSave(this.discussion.noteable_type);
} else { } else {
this.setAutoSave(); this.setAutoSave();
} }
} }
}, },
created() {
this.resolveDiscussionsSvg = resolveDiscussionsSvg;
this.nextDiscussionsSvg = nextDiscussionsSvg;
},
methods: { methods: {
...mapActions([ ...mapActions([
'saveNote', 'saveNote',
'toggleDiscussion', 'toggleDiscussion',
'removePlaceholderNotes', 'removePlaceholderNotes',
'toggleResolveNote',
]), ]),
componentName(note) { componentName(note) {
if (note.isPlaceholderNote) { if (note.isPlaceholderNote) {
...@@ -103,7 +141,7 @@ ...@@ -103,7 +141,7 @@
return noteableNote; return noteableNote;
}, },
componentData(note) { componentData(note) {
return note.isPlaceholderNote ? note.notes[0] : note; return note.isPlaceholderNote ? this.note.notes[0] : note;
}, },
toggleDiscussionHandler() { toggleDiscussionHandler() {
this.toggleDiscussion({ discussionId: this.note.id }); this.toggleDiscussion({ discussionId: this.note.id });
...@@ -128,7 +166,7 @@ ...@@ -128,7 +166,7 @@
flashContainer: this.$el, flashContainer: this.$el,
data: { data: {
in_reply_to_discussion_id: this.note.reply_id, in_reply_to_discussion_id: this.note.reply_id,
target_type: 'issue', target_type: this.noteableType,
target_id: this.discussion.noteable_id, target_id: this.discussion.noteable_id,
note: { note: noteText }, note: { note: noteText },
}, },
...@@ -152,12 +190,27 @@ Please check your network connection and try again.`; ...@@ -152,12 +190,27 @@ Please check your network connection and try again.`;
}); });
}); });
}, },
jumpToDiscussion() {
const unresolvedIds = this.unresolvedDiscussions.map(d => d.id);
const index = unresolvedIds.indexOf(this.note.id);
if (index >= 0 && index !== unresolvedIds.length) {
const nextId = unresolvedIds[index + 1];
const el = document.querySelector(`[data-discussion-id="${nextId}"]`);
if (el) {
scrollToElement(el);
}
}
},
}, },
}; };
</script> </script>
<template> <template>
<li class="note note-discussion timeline-entry"> <li
:data-discussion-id="note.id"
class="note note-discussion timeline-entry">
<div class="timeline-entry-inner"> <div class="timeline-entry-inner">
<div class="timeline-icon"> <div class="timeline-icon">
<user-avatar-link <user-avatar-link
...@@ -175,6 +228,7 @@ Please check your network connection and try again.`; ...@@ -175,6 +228,7 @@ Please check your network connection and try again.`;
:created-at="discussion.created_at" :created-at="discussion.created_at"
:note-id="discussion.id" :note-id="discussion.id"
:include-toggle="true" :include-toggle="true"
:expanded="note.expanded"
@toggleHandler="toggleDiscussionHandler" @toggleHandler="toggleDiscussionHandler"
action-text="started a discussion" action-text="started a discussion"
class="discussion" class="discussion"
...@@ -187,43 +241,103 @@ Please check your network connection and try again.`; ...@@ -187,43 +241,103 @@ Please check your network connection and try again.`;
class-name="discussion-headline-light js-discussion-headline" class-name="discussion-headline-light js-discussion-headline"
/> />
</div> </div>
</div> <div
<div v-if="note.expanded"
v-if="note.expanded" class="discussion-body">
class="discussion-body"> <component
<div class="panel panel-default"> :is="wrapperComponent"
<div class="discussion-notes"> :discussion="discussion"
<ul class="notes"> :class="wrapperClass"
<component >
v-for="note in note.notes" <div class="discussion-notes">
:is="componentName(note)" <ul class="notes">
:note="componentData(note)" <component
:key="note.id" v-for="note in note.notes"
/> :is="componentName(note)"
</ul> :note="componentData(note)"
<div :key="note.id"
:class="{ 'is-replying': isReplying }" />
class="discussion-reply-holder"> </ul>
<button <div
v-if="canReply && !isReplying" :class="{ 'is-replying': isReplying }"
@click="showReplyForm" class="discussion-reply-holder">
type="button" <template v-if="!isReplying && canReply">
class="js-vue-discussion-reply btn btn-text-field" <div
title="Add a reply"> class="btn-group-justified discussion-with-resolve-btn"
Reply... role="group">
</button> <div
<note-form class="btn-group"
v-if="isReplying" role="group">
save-button-title="Comment" <button
:discussion="note" @click="showReplyForm"
:is-editing="false" type="button"
@handleFormUpdate="saveReply" class="js-vue-discussion-reply btn btn-text-field"
@cancelFormEdition="cancelReplyForm" title="Add a reply">Reply...</button>
ref="noteForm" </div>
/> <div
<note-signed-out-widget v-if="!canReply" /> 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>
</div> </component>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -7,6 +7,8 @@ ...@@ -7,6 +7,8 @@
import noteActions from './note_actions.vue'; import noteActions from './note_actions.vue';
import noteBody from './note_body.vue'; import noteBody from './note_body.vue';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
export default { export default {
components: { components: {
...@@ -15,6 +17,10 @@ ...@@ -15,6 +17,10 @@
noteActions, noteActions,
noteBody, noteBody,
}, },
mixins: [
noteable,
resolvable,
],
props: { props: {
note: { note: {
type: Object, type: Object,
...@@ -26,6 +32,7 @@ ...@@ -26,6 +32,7 @@
isEditing: false, isEditing: false,
isDeleting: false, isDeleting: false,
isRequesting: false, isRequesting: false,
isResolving: false,
}; };
}, },
computed: { computed: {
...@@ -65,6 +72,7 @@ ...@@ -65,6 +72,7 @@
...mapActions([ ...mapActions([
'deleteNote', 'deleteNote',
'updateNote', 'updateNote',
'toggleResolveNote',
'scrollToNoteIfNeeded', 'scrollToNoteIfNeeded',
]), ]),
editHandler() { editHandler() {
...@@ -89,7 +97,7 @@ ...@@ -89,7 +97,7 @@
const data = { const data = {
endpoint: this.note.path, endpoint: this.note.path,
note: { note: {
target_type: 'issue', target_type: this.noteableType,
target_id: this.note.noteable_id, target_id: this.note.noteable_id,
note: { note: noteText }, note: { note: noteText },
}, },
...@@ -134,7 +142,7 @@ ...@@ -134,7 +142,7 @@
// we need to do this to prevent noteForm inconsistent content warning // we need to do this to prevent noteForm inconsistent content warning
// this is something we intentionally do so we need to recover the content // this is something we intentionally do so we need to recover the content
this.note.note = noteText; this.note.note = noteText;
this.$refs.noteBody.$refs.noteForm.note = noteText; this.$refs.noteBody.$refs.noteForm.note.note = noteText;
}, },
}, },
}; };
...@@ -171,8 +179,13 @@ ...@@ -171,8 +179,13 @@
:can-delete="note.current_user.can_edit" :can-delete="note.current_user.can_edit"
:can-report-as-abuse="canReportAsAbuse" :can-report-as-abuse="canReportAsAbuse"
:report-abuse-path="note.report_abuse_path" :report-abuse-path="note.report_abuse_path"
:resolvable="note.resolvable"
:is-resolved="note.resolved"
:is-resolving="isResolving"
:resolved-by="note.resolved_by"
@handleEdit="editHandler" @handleEdit="editHandler"
@handleDelete="deleteHandler" @handleDelete="deleteHandler"
@handleResolve="resolveHandler"
/> />
</div> </div>
<note-body <note-body
......
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue';
export default { export default {
name: 'NotesApp', name: 'NotesApp',
...@@ -48,7 +49,24 @@ ...@@ -48,7 +49,24 @@
...mapGetters([ ...mapGetters([
'notes', 'notes',
'getNotesDataByProp', 'getNotesDataByProp',
'discussionCount',
]), ]),
noteableType() {
// FIXME -- @fatihacet Get this from JSON data.
const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE } = constants;
return this.noteableData.merge_params ? MERGE_REQUEST_NOTEABLE_TYPE : ISSUE_NOTEABLE_TYPE;
},
allNotes() {
if (this.isLoading) {
const totalNotes = parseInt(this.notesData.totalNotes, 10) || 0;
return new Array(totalNotes).fill({
isSkeletonNote: true,
});
}
return this.notes;
},
}, },
created() { created() {
this.setNotesData(this.notesData); this.setNotesData(this.notesData);
...@@ -67,6 +85,10 @@ ...@@ -67,6 +85,10 @@
this.actionToggleAward({ awardName, noteId }); this.actionToggleAward({ awardName, noteId });
}); });
} }
document.addEventListener('refreshVueNotes', this.fetchNotes);
},
beforeDestroy() {
document.removeEventListener('refreshVueNotes', this.fetchNotes);
}, },
methods: { methods: {
...mapActions({ ...mapActions({
...@@ -81,6 +103,9 @@ ...@@ -81,6 +103,9 @@
setTargetNoteHash: 'setTargetNoteHash', setTargetNoteHash: 'setTargetNoteHash',
}), }),
getComponentName(note) { getComponentName(note) {
if (note.isSkeletonNote) {
return skeletonLoadingContainer;
}
if (note.isPlaceholderNote) { if (note.isPlaceholderNote) {
if (note.placeholderType === constants.SYSTEM_NOTE) { if (note.placeholderType === constants.SYSTEM_NOTE) {
return placeholderSystemNote; return placeholderSystemNote;
...@@ -109,9 +134,14 @@ ...@@ -109,9 +134,14 @@
}); });
}, },
initPolling() { initPolling() {
if (this.isPollingInitialized) {
return;
}
this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt')); this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt'));
this.poll(); this.poll();
this.isPollingInitialized = true;
}, },
checkLocationHash() { checkLocationHash() {
const hash = getLocationHash(); const hash = getLocationHash();
...@@ -128,25 +158,20 @@ ...@@ -128,25 +158,20 @@
<template> <template>
<div id="notes"> <div id="notes">
<div
v-if="isLoading"
class="js-loading loading">
<loading-icon />
</div>
<ul <ul
v-if="!isLoading"
id="notes-list" id="notes-list"
class="notes main-notes-list timeline"> class="notes main-notes-list timeline">
<component <component
v-for="note in notes" v-for="note in allNotes"
:is="getComponentName(note)" :is="getComponentName(note)"
:note="getComponentData(note)" :note="getComponentData(note)"
:key="note.id" :key="note.id"
/> />
</ul> </ul>
<comment-form /> <comment-form
:noteable-type="noteableType"
/>
</div> </div>
</template> </template>
export const DISCUSSION_NOTE = 'DiscussionNote'; export const DISCUSSION_NOTE = 'DiscussionNote';
export const DIFF_NOTE = 'DiffNote';
export const DISCUSSION = 'discussion'; export const DISCUSSION = 'discussion';
export const NOTE = 'note'; export const NOTE = 'note';
export const SYSTEM_NOTE = 'systemNote'; export const SYSTEM_NOTE = 'systemNote';
...@@ -8,4 +9,7 @@ export const REOPENED = 'reopened'; ...@@ -8,4 +9,7 @@ export const REOPENED = 'reopened';
export const CLOSED = 'closed'; export const CLOSED = 'closed';
export const EMOJI_THUMBSUP = 'thumbsup'; export const EMOJI_THUMBSUP = 'thumbsup';
export const EMOJI_THUMBSDOWN = 'thumbsdown'; export const EMOJI_THUMBSDOWN = 'thumbsdown';
export const NOTEABLE_TYPE = 'Issue'; export const ISSUE_NOTEABLE_TYPE = 'issue';
export const MERGE_REQUEST_NOTEABLE_TYPE = 'merge_request';
export const UNRESOLVE_NOTE_METHOD_NAME = 'delete';
export const RESOLVE_NOTE_METHOD_NAME = 'post';
...@@ -20,17 +20,7 @@ document.addEventListener('DOMContentLoaded', () => new Vue({ ...@@ -20,17 +20,7 @@ document.addEventListener('DOMContentLoaded', () => new Vue({
return { return {
noteableData: JSON.parse(notesDataset.noteableData), noteableData: JSON.parse(notesDataset.noteableData),
currentUserData, currentUserData,
notesData: { notesData: JSON.parse(notesDataset.notesData),
lastFetchedAt: notesDataset.lastFetchedAt,
discussionsPath: notesDataset.discussionsPath,
newSessionPath: notesDataset.newSessionPath,
registerPath: notesDataset.registerPath,
notesPath: notesDataset.notesPath,
markdownDocsPath: notesDataset.markdownDocsPath,
quickActionsDocsPath: notesDataset.quickActionsDocsPath,
closeIssuePath: notesDataset.closeIssuePath,
reopenIssuePath: notesDataset.reopenIssuePath,
},
}; };
}, },
render(createElement) { render(createElement) {
......
import Autosave from '../../autosave'; import Autosave from '../../autosave';
import { capitalizeFirstCharacter } from '../../lib/utils/text_utility';
export default { export default {
methods: { methods: {
initAutoSave() { initAutoSave(noteableType) {
this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', 'Issue', this.note.id], 'issue'); this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', capitalizeFirstCharacter(noteableType), this.note.id]);
}, },
resetAutoSave() { resetAutoSave() {
this.autosave.reset(); this.autosave.reset();
......
import * as constants from '../constants';
export default {
props: {
note: {
type: Object,
required: true,
},
},
computed: {
noteableType() {
switch (this.note.noteable_type) {
case 'MergeRequest':
return constants.MERGE_REQUEST_NOTEABLE_TYPE;
case 'Issue':
return constants.ISSUE_NOTEABLE_TYPE;
default:
return '';
}
},
},
};
import Flash from '~/flash';
import { __ } from '~/locale';
export default {
props: {
note: {
type: Object,
required: true,
},
},
computed: {
discussionResolved() {
const { notes, resolved } = this.note;
if (notes) { // Decide resolved state using store. Only valid for discussions.
return notes.every(note => note.resolved && !note.system);
}
return resolved;
},
resolveButtonTitle() {
if (this.updatedNoteBody) {
if (this.discussionResolved) {
return __('Comment and unresolve discussion');
}
return __('Comment and resolve discussion');
}
return this.discussionResolved ? __('Unresolve discussion') : __('Resolve discussion');
},
},
methods: {
resolveHandler(resolvedState = false) {
this.isResolving = true;
const endpoint = this.note.resolve_path || `${this.note.path}/resolve`;
const isResolved = this.discussionResolved || resolvedState;
const discussion = this.resolveAsThread;
this.toggleResolveNote({ endpoint, isResolved, discussion })
.then(() => {
this.isResolving = false;
})
.catch(() => {
this.isResolving = false;
const msg = __('Something went wrong while resolving this discussion. Please try again.');
Flash(msg, 'alert', this.$el);
});
},
},
};
import Vue from 'vue'; import Vue from 'vue';
import VueResource from 'vue-resource'; import VueResource from 'vue-resource';
import * as constants from '../constants';
Vue.use(VueResource); Vue.use(VueResource);
...@@ -19,6 +20,12 @@ export default { ...@@ -19,6 +20,12 @@ export default {
createNewNote(endpoint, data) { createNewNote(endpoint, data) {
return Vue.http.post(endpoint, data, { emulateJSON: true }); return Vue.http.post(endpoint, data, { emulateJSON: true });
}, },
toggleResolveNote(endpoint, isResolved) {
const { RESOLVE_NOTE_METHOD_NAME, UNRESOLVE_NOTE_METHOD_NAME } = constants;
const method = isResolved ? UNRESOLVE_NOTE_METHOD_NAME : RESOLVE_NOTE_METHOD_NAME;
return Vue.http[method](endpoint);
},
poll(data = {}) { poll(data = {}) {
const { endpoint, lastFetchedAt } = data; const { endpoint, lastFetchedAt } = data;
const options = { const options = {
......
...@@ -61,8 +61,17 @@ export const createNewNote = ({ commit }, { endpoint, data }) => service ...@@ -61,8 +61,17 @@ export const createNewNote = ({ commit }, { endpoint, data }) => service
export const removePlaceholderNotes = ({ commit }) => export const removePlaceholderNotes = ({ commit }) =>
commit(types.REMOVE_PLACEHOLDER_NOTES); commit(types.REMOVE_PLACEHOLDER_NOTES);
export const toggleResolveNote = ({ commit }, { endpoint, isResolved, discussion }) => service
.toggleResolveNote(endpoint, isResolved)
.then(res => res.json())
.then((res) => {
const mutationType = discussion ? types.UPDATE_DISCUSSION : types.UPDATE_NOTE;
commit(mutationType, res);
});
export const closeIssue = ({ commit, dispatch, state }) => service export const closeIssue = ({ commit, dispatch, state }) => service
.toggleIssueState(state.notesData.closeIssuePath) .toggleIssueState(state.notesData.closePath)
.then(res => res.json()) .then(res => res.json())
.then((data) => { .then((data) => {
commit(types.CLOSE_ISSUE); commit(types.CLOSE_ISSUE);
...@@ -70,7 +79,7 @@ export const closeIssue = ({ commit, dispatch, state }) => service ...@@ -70,7 +79,7 @@ export const closeIssue = ({ commit, dispatch, state }) => service
}); });
export const reopenIssue = ({ commit, dispatch, state }) => service export const reopenIssue = ({ commit, dispatch, state }) => service
.toggleIssueState(state.notesData.reopenIssuePath) .toggleIssueState(state.notesData.reopenPath)
.then(res => res.json()) .then(res => res.json())
.then((data) => { .then((data) => {
commit(types.REOPEN_ISSUE); commit(types.REOPEN_ISSUE);
...@@ -80,7 +89,7 @@ export const reopenIssue = ({ commit, dispatch, state }) => service ...@@ -80,7 +89,7 @@ export const reopenIssue = ({ commit, dispatch, state }) => service
export const emitStateChangedEvent = ({ commit, getters }, data) => { export const emitStateChangedEvent = ({ commit, getters }, data) => {
const event = new CustomEvent('issuable_vue_app:change', { detail: { const event = new CustomEvent('issuable_vue_app:change', { detail: {
data, data,
isClosed: getters.issueState === constants.CLOSED, isClosed: getters.openState === constants.CLOSED,
} }); } });
document.dispatchEvent(event); document.dispatchEvent(event);
...@@ -174,7 +183,7 @@ const pollSuccessCallBack = (resp, commit, state, getters) => { ...@@ -174,7 +183,7 @@ const pollSuccessCallBack = (resp, commit, state, getters) => {
resp.notes.forEach((note) => { resp.notes.forEach((note) => {
if (notesById[note.id]) { if (notesById[note.id]) {
commit(types.UPDATE_NOTE, note); commit(types.UPDATE_NOTE, note);
} else if (note.type === constants.DISCUSSION_NOTE) { } else if (note.type === constants.DISCUSSION_NOTE || note.type === constants.DIFF_NOTE) {
const discussion = utils.findNoteObjectById(state.notes, note.discussion_id); const discussion = utils.findNoteObjectById(state.notes, note.discussion_id);
if (discussion) { if (discussion) {
......
...@@ -8,7 +8,7 @@ export const getNotesDataByProp = state => prop => state.notesData[prop]; ...@@ -8,7 +8,7 @@ export const getNotesDataByProp = state => prop => state.notesData[prop];
export const getNoteableData = state => state.noteableData; export const getNoteableData = state => state.noteableData;
export const getNoteableDataByProp = state => prop => state.noteableData[prop]; export const getNoteableDataByProp = state => prop => state.noteableData[prop];
export const issueState = state => state.noteableData.state; export const openState = state => state.noteableData.state;
export const getUserData = state => state.userData || {}; export const getUserData = state => state.userData || {};
export const getUserDataByProp = state => prop => state.userData && state.userData[prop]; export const getUserDataByProp = state => prop => state.userData && state.userData[prop];
...@@ -30,3 +30,37 @@ export const getCurrentUserLastNote = state => _.flatten( ...@@ -30,3 +30,37 @@ export const getCurrentUserLastNote = state => _.flatten(
export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes) export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes)
.find(el => isLastNote(el, state)); .find(el => isLastNote(el, state));
export const discussionCount = (state) => {
const discussions = state.notes.filter(n => !n.individual_note);
return discussions.length;
};
export const unresolvedDiscussions = (state, getters) => {
const resolvedMap = getters.resolvedDiscussionsById;
return state.notes.filter(n => !n.individual_note && !resolvedMap[n.id]);
};
export const resolvedDiscussionsById = (state) => {
const map = {};
state.notes.forEach((n) => {
if (n.notes) {
const resolved = n.notes.every(note => note.resolved && !note.system);
if (resolved) {
map[n.id] = n;
}
}
});
return map;
};
export const resolvedDiscussionCount = (state, getters) => {
const resolvedMap = getters.resolvedDiscussionsById;
return Object.keys(resolvedMap).length;
};
...@@ -12,6 +12,7 @@ export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE'; ...@@ -12,6 +12,7 @@ export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE';
export const TOGGLE_AWARD = 'TOGGLE_AWARD'; export const TOGGLE_AWARD = 'TOGGLE_AWARD';
export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION'; export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION';
export const UPDATE_NOTE = 'UPDATE_NOTE'; export const UPDATE_NOTE = 'UPDATE_NOTE';
export const UPDATE_DISCUSSION = 'UPDATE_DISCUSSION';
// Issue // Issue
export const CLOSE_ISSUE = 'CLOSE_ISSUE'; export const CLOSE_ISSUE = 'CLOSE_ISSUE';
......
import * as utils from './utils'; import * as utils from './utils';
import * as types from './mutation_types'; import * as types from './mutation_types';
import * as constants from '../constants'; import * as constants from '../constants';
import { isInMRPage } from '../../lib/utils/common_utils';
export default { export default {
[types.ADD_NEW_NOTE](state, note) { [types.ADD_NEW_NOTE](state, note) {
const { discussion_id, type } = note; const { discussion_id, type } = note;
const [exists] = state.notes.filter(n => n.id === note.discussion_id); const [exists] = state.notes.filter(n => n.id === note.discussion_id);
const isDiscussion = (type === constants.DISCUSSION_NOTE);
if (!exists) { if (!exists) {
const noteData = { const noteData = {
expanded: true, expanded: true,
id: discussion_id, id: discussion_id,
individual_note: !(type === constants.DISCUSSION_NOTE), individual_note: !isDiscussion,
notes: [note], notes: [note],
reply_id: discussion_id, reply_id: discussion_id,
}; };
if (isDiscussion && isInMRPage()) {
noteData.resolvable = note.resolvable;
noteData.resolved = false;
noteData.resolve_path = note.resolve_path;
noteData.resolve_with_issue_path = note.resolve_with_issue_path;
}
state.notes.push(noteData); state.notes.push(noteData);
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
} }
}, },
...@@ -25,6 +35,7 @@ export default { ...@@ -25,6 +35,7 @@ export default {
if (noteObj) { if (noteObj) {
noteObj.notes.push(note); noteObj.notes.push(note);
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
} }
}, },
...@@ -41,6 +52,8 @@ export default { ...@@ -41,6 +52,8 @@ export default {
state.notes.splice(state.notes.indexOf(noteObj), 1); state.notes.splice(state.notes.indexOf(noteObj), 1);
} }
} }
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
}, },
[types.REMOVE_PLACEHOLDER_NOTES](state) { [types.REMOVE_PLACEHOLDER_NOTES](state) {
...@@ -77,15 +90,19 @@ export default { ...@@ -77,15 +90,19 @@ export default {
const notes = []; const notes = [];
notesData.forEach((note) => { notesData.forEach((note) => {
const nn = Object.assign({}, note);
// To support legacy notes, should be very rare case. // To support legacy notes, should be very rare case.
if (note.individual_note && note.notes.length > 1) { if (note.individual_note && note.notes.length > 1) {
note.notes.forEach((n) => { note.notes.forEach((n) => {
const nn = Object.assign({}, note);
nn.notes = [n]; // override notes array to only have one item to mimick individual_note nn.notes = [n]; // override notes array to only have one item to mimick individual_note
notes.push(nn); notes.push(nn);
}); });
} else { } else {
notes.push(note); const oldNote = utils.findNoteObjectById(state.notes, note.id);
nn.expanded = oldNote ? oldNote.expanded : note.expanded;
notes.push(nn);
} }
}); });
...@@ -134,6 +151,8 @@ export default { ...@@ -134,6 +151,8 @@ export default {
user: { id, name, username }, user: { id, name, username },
}); });
} }
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
}, },
[types.TOGGLE_DISCUSSION](state, { discussionId }) { [types.TOGGLE_DISCUSSION](state, { discussionId }) {
...@@ -151,6 +170,24 @@ export default { ...@@ -151,6 +170,24 @@ export default {
const comment = utils.findNoteObjectById(noteObj.notes, note.id); const comment = utils.findNoteObjectById(noteObj.notes, note.id);
noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note); noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note);
} }
// document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
},
[types.UPDATE_DISCUSSION](state, noteData) {
const note = noteData;
let index = 0;
state.notes.forEach((n, i) => {
if (n.id === note.id) {
index = i;
}
});
note.expanded = true; // override expand flag to prevent collapse
state.notes.splice(index, 1, note);
document.dispatchEvent(new CustomEvent('refreshLegacyNotes'));
}, },
[types.CLOSE_ISSUE](state) { [types.CLOSE_ISSUE](state) {
......
...@@ -28,4 +28,3 @@ export const getQuickActionText = (note) => { ...@@ -28,4 +28,3 @@ export const getQuickActionText = (note) => {
export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note); export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note);
export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim(); export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim();
...@@ -2,9 +2,9 @@ ...@@ -2,9 +2,9 @@
import { visitUrl } from '~/lib/utils/url_utility'; import { visitUrl } from '~/lib/utils/url_utility';
import UsersSelect from '~/users_select'; import UsersSelect from '~/users_select';
import { isMetaClick } from '~/lib/utils/common_utils'; import { isMetaClick } from '~/lib/utils/common_utils';
import { __ } from '../../../../locale'; import { __ } from '~/locale';
import flash from '../../../../flash'; import flash from '~/flash';
import axios from '../../../../lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
export default class Todos { export default class Todos {
constructor() { constructor() {
......
import '~/profile/gl_crop';
import Profile from '~/profile/profile';
document.addEventListener('DOMContentLoaded', () => {
$(document).on('input.ssh_key', '#key_key', function () { // eslint-disable-line func-names
const $title = $('#key_title');
const comment = $(this).val().match(/^\S+ \S+ (.+)\n?$/);
// Extract the SSH Key title from its comment
if (comment && comment.length > 1) {
$title.val(comment[1]).change();
}
});
new Profile(); // eslint-disable-line no-new
});
import initCycleAnalytics from '~/cycle_analytics/cycle_analytics_bundle';
document.addEventListener('DOMContentLoaded', initCycleAnalytics);
import initIssuableSidebar from '~/init_issuable_sidebar'; import initIssuableSidebar from '~/init_issuable_sidebar';
import initSidebarBundle from '~/sidebar/sidebar_bundle';
import Issue from '~/issue'; import Issue from '~/issue';
import ShortcutsIssuable from '~/shortcuts_issuable'; import ShortcutsIssuable from '~/shortcuts_issuable';
import ZenMode from '~/zen_mode'; import ZenMode from '~/zen_mode';
...@@ -10,4 +11,5 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -10,4 +11,5 @@ document.addEventListener('DOMContentLoaded', () => {
new ShortcutsIssuable(); // eslint-disable-line no-new new ShortcutsIssuable(); // eslint-disable-line no-new
new ZenMode(); // eslint-disable-line no-new new ZenMode(); // eslint-disable-line no-new
initIssuableSidebar(); initIssuableSidebar();
initSidebarBundle();
}); });
import initSidebarBundle from '~/sidebar/sidebar_bundle';
import initMergeConflicts from '~/merge_conflicts/merge_conflicts_bundle';
document.addEventListener('DOMContentLoaded', () => {
initSidebarBundle();
initMergeConflicts();
});
...@@ -3,6 +3,7 @@ import ZenMode from '~/zen_mode'; ...@@ -3,6 +3,7 @@ import ZenMode from '~/zen_mode';
import initNotes from '~/init_notes'; import initNotes from '~/init_notes';
import initIssuableSidebar from '~/init_issuable_sidebar'; import initIssuableSidebar from '~/init_issuable_sidebar';
import initDiffNotes from '~/diff_notes/diff_notes_bundle'; import initDiffNotes from '~/diff_notes/diff_notes_bundle';
import initSidebarBundle from '~/sidebar/sidebar_bundle';
import ShortcutsIssuable from '~/shortcuts_issuable'; import ShortcutsIssuable from '~/shortcuts_issuable';
import Diff from '~/diff'; import Diff from '~/diff';
import { handleLocationHash } from '~/lib/utils/common_utils'; import { handleLocationHash } from '~/lib/utils/common_utils';
...@@ -15,12 +16,12 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -15,12 +16,12 @@ document.addEventListener('DOMContentLoaded', () => {
new ZenMode(); // eslint-disable-line no-new new ZenMode(); // eslint-disable-line no-new
initIssuableSidebar(); initIssuableSidebar();
initSidebarBundle();
initNotes(); initNotes();
initDiffNotes(); initDiffNotes();
initPipelines(); initPipelines();
const mrShowNode = document.querySelector('.merge-request'); const mrShowNode = document.querySelector('.merge-request');
window.mergeRequest = new MergeRequest({ window.mergeRequest = new MergeRequest({
action: mrShowNode.dataset.mrAction, action: mrShowNode.dataset.mrAction,
}); });
......
import initSnippet from '~/snippet/snippet_bundle';
import initForm from '~/pages/projects/init_form'; import initForm from '~/pages/projects/init_form';
document.addEventListener('DOMContentLoaded', () => initForm($('.snippet-form'))); document.addEventListener('DOMContentLoaded', () => {
initSnippet();
initForm($('.snippet-form'));
});
import initSnippet from '~/snippet/snippet_bundle';
import initForm from '~/pages/projects/init_form'; import initForm from '~/pages/projects/init_form';
document.addEventListener('DOMContentLoaded', () => initForm($('.snippet-form'))); document.addEventListener('DOMContentLoaded', () => {
initSnippet();
initForm($('.snippet-form'));
});
import initSnippet from '~/snippet/snippet_bundle';
import form from '../form'; import form from '../form';
document.addEventListener('DOMContentLoaded', form); document.addEventListener('DOMContentLoaded', () => {
initSnippet();
form();
});
import initSnippet from '~/snippet/snippet_bundle';
import form from '../form'; import form from '../form';
document.addEventListener('DOMContentLoaded', form); document.addEventListener('DOMContentLoaded', () => {
initSnippet();
form();
});
/* eslint-disable comma-dangle, no-unused-vars, class-methods-use-this, quotes, consistent-return, func-names, prefer-arrow-callback, space-before-function-paren, max-len */ /* eslint-disable comma-dangle, no-unused-vars, class-methods-use-this, quotes, consistent-return, func-names, prefer-arrow-callback, space-before-function-paren, max-len */
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { getPagePath } from '~/lib/utils/common_utils';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale'; import { __ } from '~/locale';
import flash from '../flash'; import flash from '../flash';
((global) => { export default class Profile {
class Profile { constructor({ form } = {}) {
constructor({ form } = {}) { this.onSubmitForm = this.onSubmitForm.bind(this);
this.onSubmitForm = this.onSubmitForm.bind(this); this.form = form || $('.edit-user');
this.form = form || $('.edit-user'); this.newRepoActivated = Cookies.get('new_repo');
this.newRepoActivated = Cookies.get('new_repo'); this.setRepoRadio();
this.setRepoRadio(); this.bindEvents();
this.bindEvents(); this.initAvatarGlCrop();
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');
}
bindEvents() { initAvatarGlCrop() {
$('.js-preferences-form').on('change.preference', 'input[type=radio]', this.submitForm); const cropOpts = {
$('input[name="user[multi_file]"]').on('change', this.setNewRepoCookie); filename: '.js-avatar-filename',
$('#user_notification_email').on('change', this.submitForm); previewImage: '.avatar-image .avatar',
$('#user_notified_of_own_activity').on('change', this.submitForm); modalCrop: '.modal-profile-crop',
this.form.on('submit', this.onSubmitForm); 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() { bindEvents() {
return $(this).parents('form').submit(); $('.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) { submitForm() {
e.preventDefault(); return $(this).parents('form').submit();
return this.saveForm(); }
}
saveForm() { onSubmitForm(e) {
const self = this; e.preventDefault();
const formData = new FormData(this.form[0]); return this.saveForm();
const avatarBlob = this.avatarGlCrop.getBlob(); }
if (avatarBlob != null) { saveForm() {
formData.append('user[avatar]', avatarBlob, 'avatar.png'); const self = this;
} const formData = new FormData(this.form[0]);
const avatarBlob = this.avatarGlCrop.getBlob();
axios({ if (avatarBlob != null) {
method: this.form.attr('method'), formData.append('user[avatar]', avatarBlob, 'avatar.png');
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));
} }
setNewRepoCookie() { axios({
if (this.value === 'off') { method: this.form.attr('method'),
Cookies.remove('new_repo'); url: this.form.attr('action'),
} else { data: formData,
Cookies.set('new_repo', true, { expires_in: 365 }); })
} .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() { setNewRepoCookie() {
const multiEditRadios = $('input[name="user[multi_file]"]'); if (this.value === 'off') {
if (this.newRepoActivated || this.newRepoActivated === 'true') { Cookies.remove('new_repo');
multiEditRadios.filter('[value=on]').prop('checked', true); } else {
} else { Cookies.set('new_repo', true, { expires_in: 365 });
multiEditRadios.filter('[value=off]').prop('checked', true);
}
} }
} }
$(function() { setRepoRadio() {
$(document).on('input.ssh_key', '#key_key', function() { const multiEditRadios = $('input[name="user[multi_file]"]');
const $title = $('#key_title'); if (this.newRepoActivated || this.newRepoActivated === 'true') {
const comment = $(this).val().match(/^\S+ \S+ (.+)\n?$/); multiEditRadios.filter('[value=on]').prop('checked', true);
} else {
// Extract the SSH Key title from its comment multiEditRadios.filter('[value=off]').prop('checked', true);
if (comment && comment.length > 1) {
return $title.val(comment[1]).change();
}
});
if (getPagePath() === 'profiles') {
return new Profile();
} }
}); }
})(window.gl || (window.gl = {})); }
import './gl_crop';
import './profile';
<script>
export default { export default {
name: 'Assignees', name: 'Assignees',
data() {
return {
defaultRenderCount: 5,
defaultMaxCounter: 99,
showLess: true,
};
},
props: { props: {
rootPath: { rootPath: {
type: String, type: String,
...@@ -21,6 +15,13 @@ export default { ...@@ -21,6 +15,13 @@ export default {
required: true, required: true,
}, },
}, },
data() {
return {
defaultRenderCount: 5,
defaultMaxCounter: 99,
showLess: true,
};
},
computed: { computed: {
firstUser() { firstUser() {
return this.users[0]; return this.users[0];
...@@ -101,124 +102,131 @@ export default { ...@@ -101,124 +102,131 @@ export default {
return index === 0 || firstTwo; return index === 0 || firstTwo;
}, },
}, },
template: ` };
<div> </script>
<div
class="sidebar-collapsed-icon sidebar-collapsed-user" <template>
:class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }" <div>
data-container="body" <div
data-placement="left" class="sidebar-collapsed-icon sidebar-collapsed-user"
:title="collapsedTooltipTitle" :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 </i>
v-if="hasNoUsers" <button
aria-label="No Assignee" type="button"
class="fa fa-user" 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 <span class="author">
type="button" {{ user.name }}
class="btn-link" </span>
v-for="(user, index) in users" </button>
v-if="shouldRenderCollapsedAssignee(index)" <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 <img
width="24" width="32"
class="avatar avatar-inline s24" class="avatar avatar-inline s32"
:alt="assigneeAlt(user)" :alt="assigneeAlt(firstUser)"
:src="avatarUrl(user)" :src="avatarUrl(firstUser)"
/> />
<span class="author"> <span class="author">
{{ user.name }} {{ firstUser.name }}
</span> </span>
</button> <span class="username">
<button {{ assigneeUsername(firstUser) }}
v-if="hasMoreThanTwoAssignees"
class="btn-link"
type="button"
>
<span
class="avatar-counter sidebar-avatar-counter"
>
{{ sidebarAvatarCounter }}
</span> </span>
</button> </a>
</div> </template>
<div class="value hide-collapsed"> <template v-else>
<template v-if="hasNoUsers"> <div class="user-list">
<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>
<div <div
v-if="renderShowMoreSection" class="user-item"
class="user-list-more" v-for="(user, index) in users"
v-if="renderAssignee(index)"
:key="user.id"
> >
<button <a
type="button" class="user-link has-tooltip"
class="btn-link" data-container="body"
@click="toggleShowLess" data-placement="bottom"
:href="assigneeUrl(user)"
:data-title="user.name"
> >
<template v-if="showLess"> <img
{{ hiddenAssigneesLabel }} width="32"
</template> class="avatar avatar-inline s32"
<template v-else> :alt="assigneeAlt(user)"
- show less :src="avatarUrl(user)"
</template> />
</button> </a>
</div> </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>
`, </div>
}; </template>
import Flash from '../../../flash'; import Flash from '../../../flash';
import AssigneeTitle from './assignee_title'; import AssigneeTitle from './assignee_title';
import Assignees from './assignees'; import Assignees from './assignees.vue';
import Store from '../../stores/sidebar_store'; import Store from '../../stores/sidebar_store';
import eventHub from '../../event_hub'; import eventHub from '../../event_hub';
...@@ -28,8 +28,8 @@ export default { ...@@ -28,8 +28,8 @@ export default {
}, },
}, },
components: { components: {
'assignee-title': AssigneeTitle, AssigneeTitle,
assignees: Assignees, Assignees,
}, },
methods: { methods: {
assignSelf() { assignSelf() {
......
import Mediator from './sidebar_mediator'; import Mediator from './sidebar_mediator';
import { mountSidebar, getSidebarOptions } from './mount_sidebar'; import { mountSidebar, getSidebarOptions } from './mount_sidebar';
function domContentLoaded() { export default () => {
const mediator = new Mediator(getSidebarOptions()); const mediator = new Mediator(getSidebarOptions());
mediator.fetch(); mediator.fetch();
mountSidebar(mediator); mountSidebar(mediator);
} };
document.addEventListener('DOMContentLoaded', domContentLoaded);
export default domContentLoaded;
/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, no-var, quotes, max-len */
/* global ace */ /* global ace */
(function() { export default () => {
$(function() { const editor = ace.edit('editor');
var editor = ace.edit("editor");
$(".snippet-form-holder form").on('submit', function() { $('.snippet-form-holder form').on('submit', () => {
$(".snippet-file-content").val(editor.getValue()); $('.snippet-file-content').val(editor.getValue());
});
}); });
}).call(window); };
<script> <script>
import tooltip from '../directives/tooltip';
/** /**
* Falls back to the code used in `copy_to_clipboard.js` * Falls back to the code used in `copy_to_clipboard.js`
*/ */
import tooltip from '../directives/tooltip';
export default { export default {
name: 'ClipboardButton', name: 'ClipboardButton',
......
...@@ -11,14 +11,12 @@ ...@@ -11,14 +11,12 @@
default: false, default: false,
required: false, required: false,
}, },
isConfidential: { isConfidential: {
type: Boolean, type: Boolean,
default: false, default: false,
required: false, required: false,
}, },
}, },
computed: { computed: {
warningIcon() { warningIcon() {
if (this.isConfidential) return 'eye-slash'; if (this.isConfidential) return 'eye-slash';
...@@ -26,7 +24,6 @@ ...@@ -26,7 +24,6 @@
return ''; return '';
}, },
isLockedAndConfidential() { isLockedAndConfidential() {
return this.isConfidential && this.isLocked; return this.isConfidential && this.isLocked;
}, },
......
<template>
<li class="timeline-entry note">
<div class="timeline-entry-inner">
<div class="timeline-icon">
</div>
<div class="timeline-content">
<div class="note-header"></div>
<div class="note-body">
<skeleton-loading-container />
</div>
</div>
</div>
</li>
</template>
<script>
import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
export default {
components: {
skeletonLoadingContainer,
},
};
</script>
...@@ -103,6 +103,7 @@ ...@@ -103,6 +103,7 @@
.issuable-show-labels { .issuable-show-labels {
a { a {
margin-bottom: 5px; margin-bottom: 5px;
margin-right: 5px;
display: inline-block; display: inline-block;
.color-label { .color-label {
...@@ -116,6 +117,12 @@ ...@@ -116,6 +117,12 @@
} }
&.has-labels { &.has-labels {
// this font size is a fix to
// prevent unintended spacing between labels
// which shows up when rendering markup has white-space
// characters present.
// see: https://css-tricks.com/fighting-the-space-between-inline-block-elements/#article-header-id-3
font-size: 0;
margin-bottom: -5px; margin-bottom: -5px;
} }
} }
......
...@@ -723,7 +723,7 @@ ul.notes { ...@@ -723,7 +723,7 @@ ul.notes {
.line-resolve-all { .line-resolve-all {
vertical-align: middle; vertical-align: middle;
display: inline-block; display: inline-block;
padding: 5px 10px 6px; padding: 6px 10px;
background-color: $gray-light; background-color: $gray-light;
border: 1px solid $border-color; border: 1px solid $border-color;
border-radius: $border-radius-default; border-radius: $border-radius-default;
......
...@@ -77,6 +77,20 @@ module IssuableActions ...@@ -77,6 +77,20 @@ module IssuableActions
render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" } render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" }
end end
def discussions
notes = issuable.notes
.inc_relations_for_view
.includes(:noteable)
.fresh
notes = prepare_notes_for_rendering(notes)
notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
discussions = Discussion.build_collection(notes, issuable)
render json: DiscussionSerializer.new(project: project, noteable: issuable, current_user: current_user).represent(discussions, context: self)
end
private private
def recaptcha_check_if_spammable(should_redirect = true, &block) def recaptcha_check_if_spammable(should_redirect = true, &block)
......
...@@ -22,7 +22,7 @@ module NotesActions ...@@ -22,7 +22,7 @@ module NotesActions
notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) } notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
notes_json[:notes] = notes_json[:notes] =
if noteable.discussions_rendered_on_frontend? if use_note_serializer?
note_serializer.represent(notes) note_serializer.represent(notes)
else else
notes.map { |note| note_json(note) } notes.map { |note| note_json(note) }
...@@ -95,7 +95,7 @@ module NotesActions ...@@ -95,7 +95,7 @@ module NotesActions
if note.persisted? if note.persisted?
attrs[:valid] = true attrs[:valid] = true
if noteable.discussions_rendered_on_frontend? if use_note_serializer?
attrs.merge!(note_serializer.represent(note)) attrs.merge!(note_serializer.represent(note))
else else
attrs.merge!( attrs.merge!(
...@@ -233,4 +233,14 @@ module NotesActions ...@@ -233,4 +233,14 @@ module NotesActions
the_project the_project
end end
end end
def use_note_serializer?
return false if params['html']
if noteable.is_a?(MergeRequest)
cookies[:vue_mr_discussions] == 'true'
else
noteable.discussions_rendered_on_frontend?
end
end
end end
class Projects::DiscussionsController < Projects::ApplicationController class Projects::DiscussionsController < Projects::ApplicationController
include NotesHelper
include RendersNotes
before_action :check_merge_requests_available! before_action :check_merge_requests_available!
before_action :merge_request before_action :merge_request
before_action :discussion before_action :discussion
...@@ -7,22 +10,45 @@ class Projects::DiscussionsController < Projects::ApplicationController ...@@ -7,22 +10,45 @@ class Projects::DiscussionsController < Projects::ApplicationController
def resolve def resolve
Discussions::ResolveService.new(project, current_user, merge_request: merge_request).execute(discussion) Discussions::ResolveService.new(project, current_user, merge_request: merge_request).execute(discussion)
render json: { render_discussion
resolved_by: discussion.resolved_by.try(:name),
discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
}
end end
def unresolve def unresolve
discussion.unresolve! discussion.unresolve!
render_discussion
end
private
def render_discussion
if serialize_notes?
# TODO - It is not needed to serialize notes when resolving
# or unresolving discussions. We should remove this behavior
# passing a parameter to DiscussionEntity to return an empty array
# for notes.
# Check issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/42853
prepare_notes_for_rendering(discussion.notes, merge_request)
render_json_with_discussions_serializer
else
render_json_with_html
end
end
def render_json_with_discussions_serializer
render json:
DiscussionSerializer.new(project: project, noteable: discussion.noteable, current_user: current_user)
.represent(discussion, context: self)
end
# Legacy method used to render discussions notes when not using Vue on views.
def render_json_with_html
render json: { render json: {
resolved_by: discussion.resolved_by.try(:name),
discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion) discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
} }
end end
private
def merge_request def merge_request
@merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).find_by!(iid: params[:merge_request_id]) @merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).find_by!(iid: params[:merge_request_id])
end end
......
...@@ -60,20 +60,6 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -60,20 +60,6 @@ class Projects::IssuesController < Projects::ApplicationController
respond_with(@issue) respond_with(@issue)
end end
def discussions
notes = @issue.notes
.inc_relations_for_view
.includes(:noteable)
.fresh
notes = prepare_notes_for_rendering(notes)
notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
discussions = Discussion.build_collection(notes, @issue)
render json: DiscussionSerializer.new(project: @project, noteable: @issue, current_user: current_user).represent(discussions)
end
def create def create
create_params = issue_params.merge(spammable_params).merge( create_params = issue_params.merge(spammable_params).merge(
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
......
class Projects::NotesController < Projects::ApplicationController class Projects::NotesController < Projects::ApplicationController
include NotesActions include NotesActions
include NotesHelper
include ToggleAwardEmoji include ToggleAwardEmoji
before_action :whitelist_query_limiting, only: [:create] before_action :whitelist_query_limiting, only: [:create]
...@@ -38,10 +39,14 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -38,10 +39,14 @@ class Projects::NotesController < Projects::ApplicationController
discussion = note.discussion discussion = note.discussion
render json: { if serialize_notes?
resolved_by: note.resolved_by.try(:name), render_json_with_notes_serializer
discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion) 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 end
def unresolve def unresolve
...@@ -51,16 +56,27 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -51,16 +56,27 @@ class Projects::NotesController < Projects::ApplicationController
discussion = note.discussion discussion = note.discussion
render json: { if serialize_notes?
discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion) render_json_with_notes_serializer
} else
render json: {
discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
}
end
end end
private private
def render_json_with_notes_serializer
Notes::RenderService.new(current_user).execute([note], project)
render json: note_serializer.represent(note)
end
def note def note
@note ||= @project.notes.find(params[:id]) @note ||= @project.notes.find(params[:id])
end end
alias_method :awardable, :note alias_method :awardable, :note
def finder_params def finder_params
......
...@@ -3,7 +3,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController ...@@ -3,7 +3,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController
before_action :require_pages_enabled! before_action :require_pages_enabled!
before_action :authorize_update_pages!, except: [:show] before_action :authorize_update_pages!, except: [:show]
before_action :domain, only: [:show, :destroy, :verify] before_action :domain, except: [:new, :create]
def show def show
end end
...@@ -24,8 +24,11 @@ class Projects::PagesDomainsController < Projects::ApplicationController ...@@ -24,8 +24,11 @@ class Projects::PagesDomainsController < Projects::ApplicationController
redirect_to project_pages_domain_path(@project, @domain) redirect_to project_pages_domain_path(@project, @domain)
end end
def edit
end
def create def create
@domain = @project.pages_domains.create(pages_domain_params) @domain = @project.pages_domains.create(create_params)
if @domain.valid? if @domain.valid?
redirect_to project_pages_domain_path(@project, @domain) redirect_to project_pages_domain_path(@project, @domain)
...@@ -34,6 +37,16 @@ class Projects::PagesDomainsController < Projects::ApplicationController ...@@ -34,6 +37,16 @@ class Projects::PagesDomainsController < Projects::ApplicationController
end end
end end
def update
if @domain.update(update_params)
redirect_to project_pages_path(@project),
status: 302,
notice: 'Domain was updated'
else
render 'edit'
end
end
def destroy def destroy
@domain.destroy @domain.destroy
...@@ -49,12 +62,12 @@ class Projects::PagesDomainsController < Projects::ApplicationController ...@@ -49,12 +62,12 @@ class Projects::PagesDomainsController < Projects::ApplicationController
private private
def pages_domain_params def create_params
params.require(:pages_domain).permit( params.require(:pages_domain).permit(:key, :certificate, :domain)
:certificate, end
:key,
:domain def update_params
) params.require(:pages_domain).permit(:key, :certificate)
end end
def domain def domain
......
...@@ -151,7 +151,38 @@ module NotesHelper ...@@ -151,7 +151,38 @@ module NotesHelper
} }
end end
def notes_data(issuable)
discussions_path =
if issuable.is_a?(Issue)
discussions_project_issue_path(@project, issuable, format: :json)
else
discussions_project_merge_request_path(@project, issuable, format: :json)
end
{
discussionsPath: discussions_path,
registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'),
newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'),
markdownDocsPath: help_page_path('user/markdown'),
quickActionsDocsPath: help_page_path('user/project/quick_actions'),
closePath: close_issuable_path(issuable),
reopenPath: reopen_issuable_path(issuable),
notesPath: notes_url,
totalNotes: issuable.discussions.length,
lastFetchedAt: Time.now
}.to_json
end
def discussion_resolved_intro(discussion) def discussion_resolved_intro(discussion)
discussion.resolved_by_push? ? 'Automatically resolved' : 'Resolved' discussion.resolved_by_push? ? 'Automatically resolved' : 'Resolved'
end end
def has_vue_discussions_cookie?
cookies[:vue_mr_discussions] == 'true'
end
def serialize_notes?
has_vue_discussions_cookie? && !params['html']
end
end end
...@@ -49,7 +49,7 @@ module Ci ...@@ -49,7 +49,7 @@ module Ci
ref_protected: 1 ref_protected: 1
} }
cached_attr_reader :version, :revision, :platform, :architecture, :contacted_at cached_attr_reader :version, :revision, :platform, :architecture, :contacted_at, :ip_address
# Searches for runners matching the given query. # Searches for runners matching the given query.
# #
...@@ -157,7 +157,7 @@ module Ci ...@@ -157,7 +157,7 @@ module Ci
end end
def update_cached_info(values) def update_cached_info(values)
values = values&.slice(:version, :revision, :platform, :architecture) || {} values = values&.slice(:version, :revision, :platform, :architecture, :ip_address) || {}
values[:contacted_at] = Time.now values[:contacted_at] = Time.now
cache_attributes(values) cache_attributes(values)
......
...@@ -133,6 +133,7 @@ class Note < ActiveRecord::Base ...@@ -133,6 +133,7 @@ class Note < ActiveRecord::Base
def find_discussion(discussion_id) def find_discussion(discussion_id)
notes = where(discussion_id: discussion_id).fresh.to_a notes = where(discussion_id: discussion_id).fresh.to_a
return if notes.empty? return if notes.empty?
Discussion.build(notes) Discussion.build(notes)
......
class DiffFileEntity < Grape::Entity
include DiffHelper
include SubmoduleHelper
include BlobHelper
include IconsHelper
include ActionView::Helpers::TagHelper
expose :submodule?, as: :submodule
expose :submodule_link do |diff_file|
submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository).first
end
expose :blob_path do |diff_file|
diff_file.blob.path
end
expose :blob_icon do |diff_file|
blob_icon(diff_file.b_mode, diff_file.file_path)
end
expose :file_path
expose :deleted_file?, as: :deleted_file
expose :renamed_file?, as: :renamed_file
expose :old_path
expose :new_path
expose :mode_changed?, as: :mode_changed
expose :a_mode
expose :b_mode
expose :text?, as: :text
expose :old_path_html do |diff_file|
old_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path)
old_path
end
expose :new_path_html do |diff_file|
_, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path)
new_path
end
end
...@@ -7,4 +7,42 @@ class DiscussionEntity < Grape::Entity ...@@ -7,4 +7,42 @@ class DiscussionEntity < Grape::Entity
expose :notes, using: NoteEntity expose :notes, using: NoteEntity
expose :individual_note?, as: :individual_note expose :individual_note?, as: :individual_note
expose :resolvable?, as: :resolvable
expose :resolved?, as: :resolved
expose :resolve_path, if: -> (d, _) { d.resolvable? } do |discussion|
resolve_project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion.id)
end
expose :resolve_with_issue_path do |discussion|
new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id)
end
expose :diff_file, using: DiffFileEntity, if: -> (d, _) { defined? d.diff_file }
expose :diff_discussion?, as: :diff_discussion
expose :truncated_diff_lines, if: -> (d, _) { (defined? d.diff_file) && d.diff_file.text? } do |discussion|
options[:context].render_to_string(
partial: "projects/diffs/line",
collection: discussion.truncated_diff_lines,
as: :line,
locals: { diff_file: discussion.diff_file,
discussion_expanded: true,
plain: true },
layout: false,
formats: [:html]
)
end
expose :image_diff_html, if: -> (d, _) { defined? d.diff_file } do |discussion|
diff_file = discussion.diff_file
partial = diff_file.new_file? || diff_file.deleted_file? ? 'single_image_diff' : 'replaced_image_diff'
options[:context].render_to_string(
partial: "projects/diffs/#{partial}",
locals: { diff_file: diff_file,
position: discussion.position.to_json,
click_to_comment: false },
layout: false,
formats: [:html]
)
end
end end
...@@ -115,6 +115,14 @@ class MergeRequestWidgetEntity < IssuableEntity ...@@ -115,6 +115,14 @@ class MergeRequestWidgetEntity < IssuableEntity
expose :can_cherry_pick_on_current_merge_request do |merge_request| expose :can_cherry_pick_on_current_merge_request do |merge_request|
presenter(merge_request).can_cherry_pick_on_current_merge_request? presenter(merge_request).can_cherry_pick_on_current_merge_request?
end end
expose :can_create_note do |issue|
can?(request.current_user, :create_note, issue.project)
end
expose :can_update do |issue|
can?(request.current_user, :update_issue, issue)
end
end end
# Paths # Paths
...@@ -189,6 +197,10 @@ class MergeRequestWidgetEntity < IssuableEntity ...@@ -189,6 +197,10 @@ class MergeRequestWidgetEntity < IssuableEntity
end end
end end
expose :create_note_path do |merge_request|
project_notes_path(merge_request.project, target_type: 'merge_request', target_id: merge_request.id)
end
expose :commit_change_content_path do |merge_request| expose :commit_change_content_path do |merge_request|
commit_change_content_project_merge_request_path(merge_request.project, merge_request) commit_change_content_project_merge_request_path(merge_request.project, merge_request)
end end
......
...@@ -23,6 +23,10 @@ class NoteEntity < API::Entities::Note ...@@ -23,6 +23,10 @@ class NoteEntity < API::Entities::Note
end end
end end
expose :resolved?, as: :resolved
expose :resolvable?, as: :resolvable
expose :resolved_by, using: NoteUserEntity
expose :system_note_icon_name, if: -> (note, _) { note.system? } do |note| expose :system_note_icon_name, if: -> (note, _) { note.system? } do |note|
SystemNoteHelper.system_note_icon_name(note) SystemNoteHelper.system_note_icon_name(note)
end end
...@@ -53,6 +57,14 @@ class NoteEntity < API::Entities::Note ...@@ -53,6 +57,14 @@ class NoteEntity < API::Entities::Note
end end
end end
expose :resolve_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note|
resolve_project_merge_request_discussion_path(note.project, note.noteable, note.discussion_id)
end
expose :resolve_with_issue_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note|
new_project_issue_path(note.project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id)
end
expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? } expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? }
expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note| expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note|
delete_attachment_project_note_path(note.project, note) delete_attachment_project_note_path(note.project, note)
......
...@@ -15,6 +15,8 @@ module Projects ...@@ -15,6 +15,8 @@ module Projects
return error("Could not set the default branch") unless project.change_head(params[:default_branch]) return error("Could not set the default branch") unless project.change_head(params[:default_branch])
end end
ensure_wiki_exists if enabling_wiki?
if project.update_attributes(params.except(:default_branch)) if project.update_attributes(params.except(:default_branch))
if project.previous_changes.include?('path') if project.previous_changes.include?('path')
project.rename_repo project.rename_repo
...@@ -52,5 +54,18 @@ module Projects ...@@ -52,5 +54,18 @@ module Projects
project.repository.exists? && project.repository.exists? &&
new_branch && new_branch != project.default_branch new_branch && new_branch != project.default_branch
end end
def enabling_wiki?
return false if @project.wiki_enabled?
params[:project_feature_attributes][:wiki_access_level].to_i > ProjectFeature::DISABLED
end
def ensure_wiki_exists
ProjectWiki.new(project, project.owner).wiki
rescue ProjectWiki::CouldNotCreateWikiError
log_error("Could not create wiki for #{project.full_name}")
Gitlab::Metrics.counter(:wiki_can_not_be_created_total, 'Counts the times we failed to create a wiki')
end
end end
end end
...@@ -16,6 +16,8 @@ ...@@ -16,6 +16,8 @@
= runner.description = runner.description
%td %td
= runner.version = runner.version
%td
= runner.ip_address
%td %td
- if runner.shared? - if runner.shared?
n/a n/a
......
...@@ -60,6 +60,7 @@ ...@@ -60,6 +60,7 @@
%th Runner token %th Runner token
%th Description %th Description
%th Version %th Version
%th IP Address
%th Projects %th Projects
%th Jobs %th Jobs
%th Tags %th Tags
......
- content_for :page_specific_javascripts do
= webpack_bundle_tag('profile')
- page_title "Account" - page_title "Account"
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
- if current_user.ldap_user? - if current_user.ldap_user?
.alert.alert-info .alert.alert-info
......
- page_title "Authentication log" - page_title "Authentication log"
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
.row.prepend-top-default .row.prepend-top-default
.col-lg-4.profile-settings-sidebar .col-lg-4.profile-settings-sidebar
......
- page_title 'Chat' - page_title 'Chat'
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
.row.prepend-top-default .row.prepend-top-default
.col-lg-4.profile-settings-sidebar .col-lg-4.profile-settings-sidebar
......
- page_title "Emails" - page_title "Emails"
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
.row.prepend-top-default .row.prepend-top-default
.col-lg-4.profile-settings-sidebar .col-lg-4.profile-settings-sidebar
......
- page_title "GPG Keys" - page_title "GPG Keys"
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
.row.prepend-top-default .row.prepend-top-default
.col-lg-4.profile-settings-sidebar .col-lg-4.profile-settings-sidebar
......
- page_title "SSH Keys" - page_title "SSH Keys"
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
.row.prepend-top-default .row.prepend-top-default
.col-lg-4.profile-settings-sidebar .col-lg-4.profile-settings-sidebar
......
...@@ -2,5 +2,4 @@ ...@@ -2,5 +2,4 @@
- breadcrumb_title @key.title - breadcrumb_title @key.title
- page_title @key.title, "SSH Keys" - page_title @key.title, "SSH Keys"
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
= render "key_details" = render "key_details"
- page_title "Notifications" - page_title "Notifications"
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
%div %div
- if @user.errors.any? - if @user.errors.any?
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
- page_title "Personal Access Tokens" - page_title "Personal Access Tokens"
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
.row.prepend-top-default .row.prepend-top-default
.col-lg-4.profile-settings-sidebar .col-lg-4.profile-settings-sidebar
......
- page_title 'Preferences' - page_title 'Preferences'
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
= form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row prepend-top-default js-preferences-form' } do |f| = form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row prepend-top-default js-preferences-form' } do |f|
.col-lg-4.application-theme .col-lg-4.application-theme
......
- breadcrumb_title "Edit Profile" - breadcrumb_title "Edit Profile"
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit' }, authenticity_token: true do |f| = bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit' }, authenticity_token: true do |f|
= form_errors(@user) = form_errors(@user)
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
- add_to_breadcrumbs("Two-Factor Authentication", profile_account_path) - add_to_breadcrumbs("Two-Factor Authentication", profile_account_path)
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
- content_for :page_specific_javascripts do - content_for :page_specific_javascripts do
- if inject_u2f_api? - if inject_u2f_api?
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
- page_title "Cycle Analytics" - page_title "Cycle Analytics"
- content_for :page_specific_javascripts do - content_for :page_specific_javascripts do
= webpack_bundle_tag('common_vue') = 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) } } #cycle-analytics{ class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } }
- if @cycle_analytics_no_data - if @cycle_analytics_no_data
......
...@@ -6,14 +6,6 @@ ...@@ -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' = 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 %section.js-vue-notes-event
#js-vue-notes{ data: { discussions_path: discussions_project_issue_path(@project, @issue, format: :json), #js-vue-notes{ data: { notes_data: notes_data(@issue),
register_path: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), noteable_data: serialize_issuable(@issue),
new_session_path: new_session_path(:user, redirect_to_referer: 'yes'), current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json } }
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 } }
...@@ -73,7 +73,7 @@ ...@@ -73,7 +73,7 @@
.content-block.emoji-block .content-block.emoji-block
.row .row
.col-sm-8.js-issue-note-awards .col-sm-8.js-noteable-awards
= render 'award_emoji/awards_block', awardable: @issue, inline: true = render 'award_emoji/awards_block', awardable: @issue, inline: true
.col-sm-4.new-branch-col .col-sm-4.new-branch-col
= render 'new_branch' unless @issue.confidential? = render 'new_branch' unless @issue.confidential?
......
- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests" - page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
- content_for :page_specific_javascripts do - content_for :page_specific_javascripts do
= webpack_bundle_tag('common_vue')
= webpack_bundle_tag('merge_conflicts')
= page_specific_javascript_tag('lib/ace.js') = page_specific_javascript_tag('lib/ace.js')
= render "projects/merge_requests/mr_title" = render "projects/merge_requests/mr_title"
......
- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests" - page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
- content_for :page_specific_javascripts do - content_for :page_specific_javascripts do
= webpack_bundle_tag('common_vue')
= webpack_bundle_tag('merge_conflicts')
= page_specific_javascript_tag('lib/ace.js') = page_specific_javascript_tag('lib/ace.js')
= render "projects/merge_requests/mr_title" = render "projects/merge_requests/mr_title"
......
- @gfm_form = true
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
- add_to_breadcrumbs "Merge Requests", project_merge_requests_path(@project) - add_to_breadcrumbs "Merge Requests", project_merge_requests_path(@project)
- breadcrumb_title @merge_request.to_reference - breadcrumb_title @merge_request.to_reference
...@@ -7,6 +8,9 @@ ...@@ -7,6 +8,9 @@
- content_for :page_specific_javascripts do - content_for :page_specific_javascripts do
= webpack_bundle_tag('common_vue') = 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) } } .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" = render "projects/merge_requests/mr_title"
...@@ -23,7 +27,7 @@ ...@@ -23,7 +27,7 @@
#js-vue-mr-widget.mr-widget #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 = render 'award_emoji/awards_block', awardable: @merge_request, inline: true
.merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') } .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') }
...@@ -51,28 +55,37 @@ ...@@ -51,28 +55,37 @@
= tab_link_for @merge_request, :diffs do = tab_link_for @merge_request, :diffs do
Changes Changes
%span.badge= @merge_request.diff_size %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?}" } - if has_vue_discussions_cookie?
%div #js-vue-discussion-counter
.line-resolve-all{ "v-show" => "discussionCount > 0", - else
":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" } #resolve-count-app.line-resolve-all-container.prepend-top-10{ "v-cloak" => true }
%span.line-resolve-btn.is-disabled{ type: "button", %resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" }
":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" } %div
%template{ 'v-if' => 'resolvedDiscussionCount === discussionCount' } .line-resolve-all{ "v-show" => "discussionCount > 0",
= render 'shared/icons/icon_status_success_solid.svg' ":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" }
%template{ 'v-else' => '' } %span.line-resolve-btn.is-disabled{ type: "button",
= render 'shared/icons/icon_resolve_discussion.svg' ":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" }
%span.line-resolve-text %template{ 'v-if' => 'resolvedDiscussionCount === discussionCount' }
{{ resolvedDiscussionCount }}/{{ discussionCount }} {{ resolvedCountText }} resolved = render 'shared/icons/icon_status_success_solid.svg'
= render "discussions/new_issue_for_all_discussions", merge_request: @merge_request %template{ 'v-else' => '' }
= render "discussions/jump_to_next" = 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 .tab-content#diff-notes-app
#notes.notes.tab-pane.voting_notes #notes.notes.tab-pane.voting_notes
.row .row
%section.col-md-12 %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" = 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 #commits.commits.tab-pane
-# This tab is always loaded via AJAX -# 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?
- if @domain.errors.any? #error_explanation
#error_explanation .alert.alert-danger
.alert.alert-danger - @domain.errors.full_messages.each do |msg|
- @domain.errors.full_messages.each do |msg| %p= 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 .form-group
= f.label :domain, class: 'control-label' do = f.label :certificate, class: 'control-label' do
Domain Certificate (PEM)
.col-sm-10 .col-sm-10
= f.text_field :domain, required: true, autocomplete: 'off', class: 'form-control' = f.text_area :certificate, rows: 5, class: 'form-control'
%span.help-inline Upload a certificate for your domain with all intermediates
- 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.
.form-actions .form-group
= f.submit 'Create New Domain', class: "btn btn-save" = 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' - page_title 'New Pages Domain'
%h3.page_title %h3.page_title
New Pages Domain New Pages Domain
%hr.clearfix %hr.clearfix
%div %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' - page_title "#{@domain.domain}", 'Pages Domains'
- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled? - verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled?
- if verification_enabled && @domain.unverified? - if verification_enabled && @domain.unverified?
%p.alert.alert-warning %p.alert.alert-warning
...@@ -8,6 +11,7 @@ ...@@ -8,6 +11,7 @@
%h3.page-title %h3.page-title
Pages Domain Pages Domain
= link_to 'Edit', edit_project_pages_domain_path(@project, @domain), class: 'btn btn-success pull-right'
.table-holder .table-holder
%table.table %table.table
......
...@@ -29,6 +29,11 @@ ...@@ -29,6 +29,11 @@
Token Token
.col-sm-10 .col-sm-10
= f.text_field :token, class: 'form-control', readonly: true = 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 .form-group
= label_tag :description, class: 'control-label' do = label_tag :description, class: 'control-label' do
Description Description
......
...@@ -40,6 +40,9 @@ ...@@ -40,6 +40,9 @@
%tr %tr
%td Version %td Version
%td= @runner.version %td= @runner.version
%tr
%td IP Address
%td= @runner.ip_address
%tr %tr
%td Revision %td Revision
%td= @runner.revision %td= @runner.revision
......
- todo = issuable_todo(issuable) - todo = issuable_todo(issuable)
- content_for :page_specific_javascripts do - content_for :page_specific_javascripts do
= webpack_bundle_tag('common_vue') = 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' } %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-sidebar{ data: { endpoint: "#{issuable_json_path(issuable)}" } }
......
- issuable = @issue || @merge_request - issuable = @issue || @merge_request
- discussion_locked = issuable&.discussion_locked? - discussion_locked = issuable&.discussion_locked?
%ul#notes-list.notes.main-notes-list.timeline - unless has_vue_discussions_cookie?
= render "shared/notes/notes" %ul#notes-list.notes.main-notes-list.timeline
= render "shared/notes/notes"
= render 'shared/notes/edit_form', project: @project = render 'shared/notes/edit_form', project: @project
- if can_create_note? - if can_create_note?
%ul.notes.notes-form.timeline %ul.notes.notes-form.timeline{ :class => ('hidden' if has_vue_discussions_cookie?) }
%li.timeline-entry %li.timeline-entry
.timeline-entry-inner .timeline-entry-inner
.flash-container.timeline-content .flash-container.timeline-content
......
- content_for :page_specific_javascripts do - content_for :page_specific_javascripts do
= page_specific_javascript_tag('lib/ace.js') = page_specific_javascript_tag('lib/ace.js')
= webpack_bundle_tag('snippet')
.snippet-form-holder .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| = 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 ...@@ -103,4 +103,6 @@ Doorkeeper.configure do
# Some applications require dynamic query parameters on their request_uri # Some applications require dynamic query parameters on their request_uri
# set to true if you want this to be allowed # set to true if you want this to be allowed
# wildcard_redirect_uri false # wildcard_redirect_uri false
base_controller 'ApplicationController'
end end
...@@ -12,9 +12,14 @@ unless Sidekiq.server? ...@@ -12,9 +12,14 @@ unless Sidekiq.server?
config.lograge.logger = ActiveSupport::Logger.new(filename) config.lograge.logger = ActiveSupport::Logger.new(filename)
# Add request parameters to log output # Add request parameters to log output
config.lograge.custom_options = lambda do |event| 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 = { payload = {
time: event.time.utc.iso8601(3), time: event.time.utc.iso8601(3),
params: event.payload[:params].except(*%w(controller action format)), params: params,
remote_ip: event.payload[:remote_ip], remote_ip: event.payload[:remote_ip],
user_id: event.payload[:user_id], user_id: event.payload[:user_id],
username: event.payload[:username] username: event.payload[:username]
......
...@@ -55,7 +55,7 @@ constraints(ProjectUrlConstrainer.new) do ...@@ -55,7 +55,7 @@ constraints(ProjectUrlConstrainer.new) do
end end
resource :pages, only: [:show, :destroy] do 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 member do
post :verify post :verify
end end
...@@ -103,6 +103,7 @@ constraints(ProjectUrlConstrainer.new) do ...@@ -103,6 +103,7 @@ constraints(ProjectUrlConstrainer.new) do
post :toggle_subscription post :toggle_subscription
post :remove_wip post :remove_wip
post :assign_related_issues post :assign_related_issues
get :discussions, format: :json
post :rebase post :rebase
scope constraints: { format: nil }, action: :show do scope constraints: { format: nil }, action: :show do
......
...@@ -44,31 +44,24 @@ function generateEntries() { ...@@ -44,31 +44,24 @@ function generateEntries() {
const manualEntries = { const manualEntries = {
balsamiq_viewer: './blob/balsamiq_viewer.js', 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', environments: './environments/environments_bundle.js',
filtered_search: './filtered_search/filtered_search_bundle.js', filtered_search: './filtered_search/filtered_search_bundle.js',
help: './help/help.js', help: './help/help.js',
merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js',
monitoring: './monitoring/monitoring_bundle.js', monitoring: './monitoring/monitoring_bundle.js',
mr_notes: './mr_notes/index.js',
notebook_viewer: './blob/notebook_viewer.js', notebook_viewer: './blob/notebook_viewer.js',
pdf_viewer: './blob/pdf_viewer.js', pdf_viewer: './blob/pdf_viewer.js',
pipelines_details: './pipelines/pipeline_details_bundle.js', pipelines_details: './pipelines/pipeline_details_bundle.js',
profile: './profile/profile_bundle.js',
project_import_gl: './projects/project_import_gitlab_project.js', project_import_gl: './projects/project_import_gitlab_project.js',
protected_branches: './protected_branches', protected_branches: './protected_branches',
protected_tags: './protected_tags', protected_tags: './protected_tags',
registry_list: './registry/index.js', registry_list: './registry/index.js',
sidebar: './sidebar/sidebar_bundle.js',
snippet: './snippet/snippet_bundle.js',
sketch_viewer: './blob/sketch_viewer.js', sketch_viewer: './blob/sketch_viewer.js',
stl_viewer: './blob/stl_viewer.js', stl_viewer: './blob/stl_viewer.js',
terminal: './terminal/terminal_bundle.js', terminal: './terminal/terminal_bundle.js',
ui_development_kit: './ui_development_kit.js', ui_development_kit: './ui_development_kit.js',
two_factor_auth: './two_factor_auth.js', two_factor_auth: './two_factor_auth.js',
common: './commons/index.js', common: './commons/index.js',
common_vue: './vue_shared/vue_resource_interceptor.js', common_vue: './vue_shared/vue_resource_interceptor.js',
locale: './locale/index.js', locale: './locale/index.js',
...@@ -239,13 +232,12 @@ var config = { ...@@ -239,13 +232,12 @@ var config = {
name: 'common_vue', name: 'common_vue',
chunks: [ chunks: [
'boards', 'boards',
'cycle_analytics',
'deploy_keys', 'deploy_keys',
'environments', 'environments',
'filtered_search', 'filtered_search',
'groups', 'groups',
'merge_conflicts',
'monitoring', 'monitoring',
'mr_notes',
'notebook_viewer', 'notebook_viewer',
'pdf_viewer', 'pdf_viewer',
'pipelines', 'pipelines',
...@@ -363,7 +355,7 @@ if (IS_DEV_SERVER) { ...@@ -363,7 +355,7 @@ if (IS_DEV_SERVER) {
callback(); callback();
}) })
}, },
}, }
); );
if (DEV_SERVER_LIVERELOAD) { if (DEV_SERVER_LIVERELOAD) {
config.plugins.push(new webpack.HotModuleReplacementPlugin()); 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 @@ ...@@ -11,7 +11,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
...@@ -437,6 +437,7 @@ ActiveRecord::Schema.define(version: 20180216121030) do ...@@ -437,6 +437,7 @@ ActiveRecord::Schema.define(version: 20180216121030) do
t.boolean "run_untagged", default: true, null: false t.boolean "run_untagged", default: true, null: false
t.boolean "locked", default: false, null: false t.boolean "locked", default: false, null: false
t.integer "access_level", default: 0, null: false t.integer "access_level", default: 0, null: false
t.string "ip_address"
end end
add_index "ci_runners", ["contacted_at"], name: "index_ci_runners_on_contacted_at", using: :btree add_index "ci_runners", ["contacted_at"], name: "index_ci_runners_on_contacted_at", using: :btree
......
--- ---
toc: false
comments: false comments: false
--- ---
...@@ -8,15 +7,9 @@ comments: false ...@@ -8,15 +7,9 @@ comments: false
Welcome to [GitLab](https://about.gitlab.com/), a Git-based fully featured Welcome to [GitLab](https://about.gitlab.com/), a Git-based fully featured
platform for software development! platform for software development!
GitLab offers the most scalable Git-based fully integrated platform for software development, with flexible products and subscription plans. GitLab offers the most scalable Git-based fully integrated platform for
software development, with flexible products and subscriptions.
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. To understand what features you have access to, check the [GitLab subscriptions](#gitlab-subscriptions) below.
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.
## Shortcuts to GitLab's most visited docs ## Shortcuts to GitLab's most visited docs
...@@ -124,8 +117,6 @@ Manage your [repositories](user/project/repository/index.md) from the UI (user i ...@@ -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. - [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 - [Trello Power-Up](integration/trello_power_up.md): Integrate with GitLab's Trello Power-Up
----
## Administrator documentation ## Administrator documentation
[Administration documentation](administration/index.md) applies to admin users of GitLab [Administration documentation](administration/index.md) applies to admin users of GitLab
...@@ -143,3 +134,42 @@ Learn how to contribute to GitLab: ...@@ -143,3 +134,42 @@ Learn how to contribute to GitLab:
- [Development](development/README.md): All styleguides and explanations how to contribute. - [Development](development/README.md): All styleguides and explanations how to contribute.
- [Legal](legal/README.md): Contributor license agreements. - [Legal](legal/README.md): Contributor license agreements.
- [Writing documentation](development/writing_documentation.md): Contributing to GitLab Docs. - [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.** ...@@ -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_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_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_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_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_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 | | **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: ...@@ -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 Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import * as actions from './actions'; import * as actions from './actions';
import * as getters from './getters';
import * as mutations from './mutations'; import * as mutations from './mutations';
Vue.use(Vuex); Vue.use(Vuex);
...@@ -514,6 +515,7 @@ Vue.use(Vuex); ...@@ -514,6 +515,7 @@ Vue.use(Vuex);
export default new Vuex.Store({ export default new Vuex.Store({
actions, actions,
getters, getters,
mutations,
state: { state: {
users: [], users: [],
}, },
......
...@@ -109,8 +109,7 @@ in your SAML IdP: ...@@ -109,8 +109,7 @@ in your SAML IdP:
1. Change the value of `issuer` to a unique name, which will identify the application 1. Change the value of `issuer` to a unique name, which will identify the application
to the IdP. to the IdP.
1. [Reconfigure][] or [restart GitLab][] for the changes to take effect if you 1. For the changes to take effect, you must [reconfigure][] GitLab if you installed via Omnibus or [restart GitLab][] if you installed from source.
installed GitLab via Omnibus or from source respectively.
1. Register the GitLab SP in your SAML 2.0 IdP, using the application name specified 1. Register the GitLab SP in your SAML 2.0 IdP, using the application name specified
in `issuer`. in `issuer`.
......
...@@ -36,12 +36,16 @@ GFM honors the markdown specification in how [paragraphs and line breaks are han ...@@ -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. 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: 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 Violets are blue
Sugar is sweet 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 Violets are blue
Sugar is sweet Sugar is sweet
......
...@@ -34,7 +34,7 @@ With **[GitLab Enterprise Edition][ee]**, you can also: ...@@ -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) - 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) - 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) - [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 ## Use cases
......
...@@ -9,16 +9,22 @@ module API ...@@ -9,16 +9,22 @@ module API
Gitlab::CurrentSettings.runners_registration_token) Gitlab::CurrentSettings.runners_registration_token)
end end
def get_runner_version_from_params def authenticate_runner!
return unless params['info'].present? 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 end
def authenticate_runner! def get_runner_details_from_request
forbidden! unless current_runner 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 end
def current_runner def current_runner
......
...@@ -16,7 +16,8 @@ module API ...@@ -16,7 +16,8 @@ module API
optional :tag_list, type: Array[String], desc: %q(List of Runner's tags) optional :tag_list, type: Array[String], desc: %q(List of Runner's tags)
end end
post '/' do 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 = runner =
if runner_registration_token_valid? if runner_registration_token_valid?
...@@ -30,7 +31,6 @@ module API ...@@ -30,7 +31,6 @@ module API
return forbidden! unless runner return forbidden! unless runner
if runner.id if runner.id
runner.update(get_runner_version_from_params)
present runner, with: Entities::RunnerRegistrationDetails present runner, with: Entities::RunnerRegistrationDetails
else else
not_found! not_found!
......
...@@ -59,7 +59,7 @@ module Gitlab ...@@ -59,7 +59,7 @@ module Gitlab
end end
def pages(limit: nil) 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 if is_enabled
gitaly_get_all_pages gitaly_get_all_pages
else else
...@@ -68,9 +68,8 @@ module Gitlab ...@@ -68,9 +68,8 @@ module Gitlab
end end
end end
# Disable because of https://gitlab.com/gitlab-org/gitlab-ce/issues/42039
def page(title:, version: nil, dir: nil) 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 if is_enabled
gitaly_find_page(title: title, version: version, dir: dir) gitaly_find_page(title: title, version: version, dir: dir)
else else
......
module Gitlab module Gitlab
class SearchResults class SearchResults
class FoundBlob class FoundBlob
include EncodingHelper
attr_reader :id, :filename, :basename, :ref, :startline, :data, :project_id attr_reader :id, :filename, :basename, :ref, :startline, :data, :project_id
def initialize(opts = {}) def initialize(opts = {})
...@@ -9,7 +11,7 @@ module Gitlab ...@@ -9,7 +11,7 @@ module Gitlab
@basename = opts.fetch(:basename, nil) @basename = opts.fetch(:basename, nil)
@ref = opts.fetch(:ref, nil) @ref = opts.fetch(:ref, nil)
@startline = opts.fetch(:startline, 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) @per_page = opts.fetch(:per_page, 20)
@project_id = opts.fetch(:project_id, nil) @project_id = opts.fetch(:project_id, nil)
end end
......
...@@ -21,6 +21,8 @@ else ...@@ -21,6 +21,8 @@ else
File.open(hook_path, 'w') do |file| File.open(hook_path, 'w') do |file|
IO.copy_stream(DATA, file) IO.copy_stream(DATA, file)
end end
File.chmod(0755, hook_path)
end end
# Toggle the harness on or off # Toggle the harness on or off
......
...@@ -34,6 +34,8 @@ describe Oauth::AuthorizationsController do ...@@ -34,6 +34,8 @@ describe Oauth::AuthorizationsController do
end end
context 'with valid params' do context 'with valid params' do
render_views
it 'returns 200 code and renders view' do it 'returns 200 code and renders view' do
get :new, params get :new, params
......
...@@ -71,6 +71,19 @@ describe Projects::DiscussionsController do ...@@ -71,6 +71,19 @@ describe Projects::DiscussionsController do
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
end 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 end
end end
...@@ -119,6 +132,19 @@ describe Projects::DiscussionsController do ...@@ -119,6 +132,19 @@ describe Projects::DiscussionsController do
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
end 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 end
end end
......
...@@ -974,7 +974,7 @@ describe Projects::IssuesController do ...@@ -974,7 +974,7 @@ describe Projects::IssuesController do
it 'returns discussion json' do it 'returns discussion json' do
get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid 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 end
context 'with cross-reference system note', :request_store do context 'with cross-reference system note', :request_store do
......
...@@ -53,6 +53,66 @@ describe Projects::PagesDomainsController do ...@@ -53,6 +53,66 @@ describe Projects::PagesDomainsController do
end end
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 describe 'POST verify' do
let(:params) { request_params.merge(id: pages_domain.domain) } let(:params) { request_params.merge(id: pages_domain.domain) }
......
...@@ -144,7 +144,7 @@ describe 'Merge request > User posts notes', :js do ...@@ -144,7 +144,7 @@ describe 'Merge request > User posts notes', :js do
end end
end end
describe 'deleting an attachment' do describe 'deleting attachment on legacy diff note' do
before do before do
find('.note').hover find('.note').hover
......
...@@ -160,6 +160,37 @@ feature 'Pages' do ...@@ -160,6 +160,37 @@ feature 'Pages' do
expect(page).to have_content('my.test.domain.com') expect(page).to have_content('my.test.domain.com')
end 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
end end
......
...@@ -367,23 +367,6 @@ describe 'Pipelines', :js do ...@@ -367,23 +367,6 @@ describe 'Pipelines', :js do
expect(build.reload).to be_canceled expect(build.reload).to be_canceled
end end
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 end
context 'with pagination' do context 'with pagination' do
......
...@@ -75,7 +75,9 @@ ...@@ -75,7 +75,9 @@
"properties": { "properties": {
"can_remove_source_branch": { "type": "boolean" }, "can_remove_source_branch": { "type": "boolean" },
"can_revert_on_current_merge_request": { "type": ["boolean", "null"] }, "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 "additionalProperties": false
}, },
...@@ -103,6 +105,7 @@ ...@@ -103,6 +105,7 @@
"merge_ongoing": { "type": "boolean" }, "merge_ongoing": { "type": "boolean" },
"ff_only_enabled": { "type": ["boolean", false] }, "ff_only_enabled": { "type": ["boolean", false] },
"should_be_rebased": { "type": "boolean" }, "should_be_rebased": { "type": "boolean" },
"create_note_path": { "type": ["string", "null"] },
"rebase_commit_sha": { "type": ["string", "null"] }, "rebase_commit_sha": { "type": ["string", "null"] },
"rebase_in_progress": { "type": "boolean" }, "rebase_in_progress": { "type": "boolean" },
"can_push_to_source_branch": { "type": "boolean" }, "can_push_to_source_branch": { "type": "boolean" },
......
...@@ -3,28 +3,24 @@ import AccessorUtilities from '~/lib/utils/accessor'; ...@@ -3,28 +3,24 @@ import AccessorUtilities from '~/lib/utils/accessor';
describe('Autosave', () => { describe('Autosave', () => {
let autosave; let autosave;
const field = $('<textarea></textarea>');
const key = 'key';
describe('class constructor', () => { describe('class constructor', () => {
const key = 'key';
const field = jasmine.createSpyObj('field', ['data', 'on']);
beforeEach(() => { beforeEach(() => {
spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.returnValue(true); spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.returnValue(true);
spyOn(Autosave.prototype, 'restore'); spyOn(Autosave.prototype, 'restore');
autosave = new Autosave(field, key);
}); });
it('should set .isLocalStorageAvailable', () => { it('should set .isLocalStorageAvailable', () => {
autosave = new Autosave(field, key);
expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled();
expect(autosave.isLocalStorageAvailable).toBe(true); expect(autosave.isLocalStorageAvailable).toBe(true);
}); });
}); });
describe('restore', () => { describe('restore', () => {
const key = 'key';
const field = jasmine.createSpyObj('field', ['trigger']);
beforeEach(() => { beforeEach(() => {
autosave = { autosave = {
field, field,
...@@ -49,24 +45,53 @@ describe('Autosave', () => { ...@@ -49,24 +45,53 @@ describe('Autosave', () => {
describe('if .isLocalStorageAvailable is `true`', () => { describe('if .isLocalStorageAvailable is `true`', () => {
beforeEach(() => { beforeEach(() => {
autosave.isLocalStorageAvailable = true; autosave.isLocalStorageAvailable = true;
Autosave.prototype.restore.call(autosave);
}); });
it('should call .getItem', () => { it('should call .getItem', () => {
Autosave.prototype.restore.call(autosave);
expect(window.localStorage.getItem).toHaveBeenCalledWith(key); 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', () => { describe('save', () => {
const field = jasmine.createSpyObj('field', ['val']);
beforeEach(() => { beforeEach(() => {
autosave = jasmine.createSpyObj('autosave', ['reset']); autosave = jasmine.createSpyObj('autosave', ['reset']);
autosave.field = field; autosave.field = field;
field.val('value');
field.val.and.returnValue('value');
spyOn(window.localStorage, 'setItem'); spyOn(window.localStorage, 'setItem');
}); });
...@@ -97,8 +122,6 @@ describe('Autosave', () => { ...@@ -97,8 +122,6 @@ describe('Autosave', () => {
}); });
describe('reset', () => { describe('reset', () => {
const key = 'key';
beforeEach(() => { beforeEach(() => {
autosave = { autosave = {
key, key,
......
...@@ -70,8 +70,50 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont ...@@ -70,8 +70,50 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont
render_merge_request(example.description, merge_request) render_merge_request(example.description, merge_request)
end 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 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) def render_merge_request(fixture_file_name, merge_request)
get :show, get :show,
namespace_id: project.namespace.to_param, 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 Vue from 'vue';
import Autosize from 'autosize'; import Autosize from 'autosize';
import store from '~/notes/stores'; 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 { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data';
import { keyboardDownEvent } from '../../issue_show/helpers'; import { keyboardDownEvent } from '../../issue_show/helpers';
describe('issue_comment_form component', () => { describe('issue_comment_form component', () => {
let vm; let vm;
const Component = Vue.extend(issueCommentForm); const Component = Vue.extend(CommentForm);
let mountComponent; let mountComponent;
beforeEach(() => { beforeEach(() => {
mountComponent = () => new Component({ mountComponent = (noteableType = 'issue') => new Component({
propsData: {
noteableType,
},
store, store,
}).$mount(); }).$mount();
}); });
...@@ -136,6 +139,11 @@ describe('issue_comment_form component', () => { ...@@ -136,6 +139,11 @@ describe('issue_comment_form component', () => {
expect(vm.editCurrentUserLastNote).toHaveBeenCalled(); expect(vm.editCurrentUserLastNote).toHaveBeenCalled();
}); });
it('inits autosave', () => {
expect(vm.autosave).toBeDefined();
expect(vm.autosave.key).toEqual(`autosave/Note/Issue/${noteableDataMock.id}`);
});
}); });
describe('event enter', () => { describe('event enter', () => {
...@@ -182,6 +190,15 @@ describe('issue_comment_form component', () => { ...@@ -182,6 +190,15 @@ describe('issue_comment_form component', () => {
done(); 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', () => { 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', () => { ...@@ -24,6 +24,7 @@ describe('note_app', () => {
beforeEach(() => { beforeEach(() => {
jasmine.addMatchers(vueMatchers); jasmine.addMatchers(vueMatchers);
$('body').attr('data-page', 'projects:merge_requests:show');
const IssueNotesApp = Vue.extend(notesApp); const IssueNotesApp = Vue.extend(notesApp);
...@@ -119,8 +120,8 @@ describe('note_app', () => { ...@@ -119,8 +120,8 @@ describe('note_app', () => {
vm = mountComponent(); vm = mountComponent();
}); });
it('should render loading icon', () => { it('renders skeleton notes', () => {
expect(vm).toIncludeElement('.js-loading'); expect(vm).toIncludeElement('.animation-container');
}); });
it('should render form', () => { it('should render form', () => {
......
...@@ -30,17 +30,26 @@ describe('issue_note_body component', () => { ...@@ -30,17 +30,26 @@ describe('issue_note_body component', () => {
expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html); expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html);
}); });
it('should be render form if user is editing', (done) => { it('should render awards list', () => {
vm.isEditing = true; 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(() => { describe('isEditing', () => {
expect(vm.$el.querySelector('textarea.js-task-list-field')).toBeDefined(); beforeEach((done) => {
done(); vm.isEditing = true;
Vue.nextTick(done);
}); });
});
it('should render awards list', () => { it('renders edit form', () => {
expect(vm.$el.querySelector('.js-awards-block button [data-name="baseball"]')).toBeDefined(); expect(vm.$el.querySelector('textarea.js-task-list-field')).not.toBeNull();
expect(vm.$el.querySelector('.js-awards-block button [data-name="bath_tone3"]')).toBeDefined(); });
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', () => { ...@@ -32,6 +32,7 @@ describe('note_header component', () => {
createdAt: '2017-08-02T10:51:58.559Z', createdAt: '2017-08-02T10:51:58.559Z',
includeToggle: false, includeToggle: false,
noteId: 1394, noteId: 1394,
expanded: true,
}, },
}).$mount(); }).$mount();
}); });
...@@ -68,6 +69,7 @@ describe('note_header component', () => { ...@@ -68,6 +69,7 @@ describe('note_header component', () => {
createdAt: '2017-08-02T10:51:58.559Z', createdAt: '2017-08-02T10:51:58.559Z',
includeToggle: true, includeToggle: true,
noteId: 1395, noteId: 1395,
expanded: true,
}, },
}).$mount(); }).$mount();
}); });
...@@ -76,17 +78,35 @@ describe('note_header component', () => { ...@@ -76,17 +78,35 @@ describe('note_header component', () => {
expect(vm.$el.querySelector('.js-vue-toggle-button')).toBeDefined(); expect(vm.$el.querySelector('.js-vue-toggle-button')).toBeDefined();
}); });
it('should toggle the disucssion icon', (done) => { it('emits toggle event on click', (done) => {
expect( spyOn(vm, '$emit');
vm.$el.querySelector('.js-vue-toggle-button i').classList.contains('fa-chevron-up'),
).toEqual(true);
vm.$el.querySelector('.js-vue-toggle-button').click(); 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(() => { Vue.nextTick(() => {
expect( expect(
vm.$el.querySelector('.js-vue-toggle-button i').classList.contains('fa-chevron-down'), vm.$el.querySelector('.js-vue-toggle-button i').classList,
).toEqual(true); ).toContain('fa-chevron-down');
done(); done();
}); });
}); });
......
...@@ -7,8 +7,9 @@ export const notesDataMock = { ...@@ -7,8 +7,9 @@ export const notesDataMock = {
notesPath: '/gitlab-org/gitlab-ce/noteable/issue/98/notes', notesPath: '/gitlab-org/gitlab-ce/noteable/issue/98/notes',
quickActionsDocsPath: '/help/user/project/quick_actions', quickActionsDocsPath: '/help/user/project/quick_actions',
registerPath: '/users/sign_in?redirect_to_referer=yes#register-pane', registerPath: '/users/sign_in?redirect_to_referer=yes#register-pane',
closeIssuePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=close', totalNotes: 1,
reopenIssuePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=reopen', 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 = { export const userDataMock = {
......
...@@ -56,9 +56,9 @@ describe('Getters Notes Store', () => { ...@@ -56,9 +56,9 @@ describe('Getters Notes Store', () => {
}); });
}); });
describe('issueState', () => { describe('openState', () => {
it('should return the issue state', () => { 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 mutations from '~/notes/stores/mutations';
import { note, discussionMock, notesDataMock, userDataMock, noteableDataMock, individualNote } from '../mock_data'; import { note, discussionMock, notesDataMock, userDataMock, noteableDataMock, individualNote } from '../mock_data';
describe('Mutation Notes Store', () => { describe('Notes Store mutations', () => {
describe('ADD_NEW_NOTE', () => { describe('ADD_NEW_NOTE', () => {
let state; let state;
let noteData; let noteData;
...@@ -103,7 +103,8 @@ describe('Mutation Notes Store', () => { ...@@ -103,7 +103,8 @@ describe('Mutation Notes Store', () => {
}; };
mutations.SET_INITIAL_NOTES(state, [note]); 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'; ...@@ -34,6 +34,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
describe('Notes', function() { describe('Notes', function() {
const FLASH_TYPE_ALERT = 'alert'; const FLASH_TYPE_ALERT = 'alert';
const NOTES_POST_PATH = /(.*)\/notes\?html=true$/;
var commentsTemplate = 'merge_requests/merge_request_with_comment.html.raw'; var commentsTemplate = 'merge_requests/merge_request_with_comment.html.raw';
preloadFixtures(commentsTemplate); preloadFixtures(commentsTemplate);
...@@ -154,7 +155,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; ...@@ -154,7 +155,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
$form.find('textarea.js-note-text').val(sampleComment); $form.find('textarea.js-note-text').val(sampleComment);
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
mock.onPost(/(.*)\/notes$/).reply(200, noteEntity); mock.onPost(NOTES_POST_PATH).reply(200, noteEntity);
}); });
afterEach(() => { afterEach(() => {
...@@ -506,11 +507,11 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; ...@@ -506,11 +507,11 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
let mock; let mock;
function mockNotesPost() { function mockNotesPost() {
mock.onPost(/(.*)\/notes$/).reply(200, note); mock.onPost(NOTES_POST_PATH).reply(200, note);
} }
function mockNotesPostError() { function mockNotesPostError() {
mock.onPost(/(.*)\/notes$/).networkError(); mock.onPost(NOTES_POST_PATH).networkError();
} }
beforeEach(() => { beforeEach(() => {
...@@ -631,7 +632,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; ...@@ -631,7 +632,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
beforeEach(() => { beforeEach(() => {
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
mock.onPost(/(.*)\/notes$/).reply(200, note); mock.onPost(NOTES_POST_PATH).reply(200, note);
this.notes = new Notes('', []); this.notes = new Notes('', []);
window.gon.current_username = 'root'; window.gon.current_username = 'root';
...@@ -684,7 +685,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; ...@@ -684,7 +685,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper';
beforeEach(() => { beforeEach(() => {
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
mock.onPost(/(.*)\/notes$/).reply(200, note); mock.onPost(NOTES_POST_PATH).reply(200, note);
this.notes = new Notes('', []); this.notes = new Notes('', []);
window.gon.current_username = 'root'; window.gon.current_username = 'root';
......
import Vue from 'vue'; 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 UsersMock from './mock_data';
import UsersMockHelper from '../helpers/user_mock_data_helper'; import UsersMockHelper from '../helpers/user_mock_data_helper';
......
# coding: utf-8
require 'spec_helper' require 'spec_helper'
describe Gitlab::ProjectSearchResults do describe Gitlab::ProjectSearchResults do
...@@ -105,6 +106,32 @@ describe Gitlab::ProjectSearchResults do ...@@ -105,6 +106,32 @@ describe Gitlab::ProjectSearchResults do
end end
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 context "when filename has extension" do
let(:search_result) { "master:CONTRIBUTE.md\x005\x00- [Contribute to GitLab](#contribute-to-gitlab)\n" } let(:search_result) { "master:CONTRIBUTE.md\x005\x00- [Contribute to GitLab](#contribute-to-gitlab)\n" }
......
...@@ -122,6 +122,15 @@ describe API::Runner do ...@@ -122,6 +122,15 @@ describe API::Runner do
end end
end 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 end
describe 'DELETE /api/v4/runners' do describe 'DELETE /api/v4/runners' do
...@@ -422,6 +431,15 @@ describe API::Runner do ...@@ -422,6 +431,15 @@ describe API::Runner do
end end
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 context 'when concurrently updating a job' do
before do before do
expect_any_instance_of(Ci::Build).to receive(:run!) 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 ...@@ -48,4 +48,15 @@ describe NoteEntity do
expect(subject).to include(:system_note_icon_name) expect(subject).to include(:system_note_icon_name)
end end
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 end
...@@ -123,6 +123,40 @@ describe Projects::UpdateService do ...@@ -123,6 +123,40 @@ describe Projects::UpdateService do
end end
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 context 'when updating a project that contains container images' do
before do before do
stub_container_registry_config(enabled: true) 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