Merge branch 'master' into sh-headless-chrome-support

This commit is contained in:
Stan Hu 2017-09-05 11:33:09 -07:00
commit 41e5ec8f74
683 changed files with 23841 additions and 5796 deletions

View File

@ -125,6 +125,7 @@ stages:
- export KNAPSACK_GENERATE_REPORT=true
- export CACHE_CLASSES=true
- cp ${KNAPSACK_SPINACH_SUITE_REPORT_PATH} ${KNAPSACK_REPORT_PATH}
- scripts/gitaly-test-spawn
- knapsack spinach "-r rerun" || retry '[[ -e tmp/spinach-rerun.txt ]] && bundle exec spinach -r rerun $(cat tmp/spinach-rerun.txt)'
artifacts:
expire_in: 31d
@ -207,11 +208,10 @@ update-tests-metadata:
- '[[ -z ${TESTS_METADATA_S3_BUCKET} ]] || scripts/sync-reports put $TESTS_METADATA_S3_BUCKET $KNAPSACK_RSPEC_SUITE_REPORT_PATH $KNAPSACK_SPINACH_SUITE_REPORT_PATH'
- '[[ -z ${TESTS_METADATA_S3_BUCKET} ]] || scripts/sync-reports put $TESTS_METADATA_S3_BUCKET $FLAKY_RSPEC_SUITE_REPORT_PATH'
- rm -f knapsack/${CI_PROJECT_NAME}/*_node_*.json
- rm -f rspec_flaky/${CI_PROJECT_NAME}/all_node_*.json
- rm -f rspec_flaky/${CI_PROJECT_NAME}/*_node_*.json
flaky-examples-check:
<<: *dedicated-runner
<<: *except-docs
image: ruby:2.3-alpine
services: []
before_script: []
@ -226,6 +226,7 @@ flaky-examples-check:
- branches
except:
- master
- /(^docs[\/-].*|.*-docs$)/
artifacts:
expire_in: 30d
paths:

View File

@ -2,6 +2,24 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
## 9.5.3 (2017-09-03)
- [SECURITY] Filter additional secrets from Rails logs.
- [FIXED] Make username update fail if the namespace update fails. !13642
- [FIXED] Fix failure when issue is authored by a deleted user. !13807
- [FIXED] Reverts changes made to signin_enabled. !13956
- [FIXED] Fix Merge when pipeline succeeds button dropdown caret icon horizontal alignment.
- [FIXED] Fixed diff changes bar buttons from showing/hiding whilst scrolling.
- [FIXED] Fix events error importing GitLab projects.
- [FIXED] Fix pipeline trigger via API fails with 500 Internal Server Error in 9.5.
- [FIXED] Fixed fly-out nav flashing in & out.
- [FIXED] Remove closing external issues by reference error.
- [FIXED] Re-allow appearances.description_html to be NULL.
- [CHANGED] Update and fix resolvable note icons for easier recognition.
- [OTHER] Eager load head pipeline projects for MRs index.
- [OTHER] Instrument MergeRequest#fetch_ref.
- [OTHER] Instrument MergeRequest#ensure_ref_fetched.
## 9.5.2 (2017-08-28)
- [FIXED] Fix signing in using LDAP when attribute mapping uses simple strings instead of arrays.

View File

@ -1 +1 @@
0.35.0
0.37.0

View File

@ -349,6 +349,8 @@ group :development, :test do
gem 'activerecord_sane_schema_dumper', '0.2'
gem 'stackprof', '~> 0.2.10', require: false
gem 'simple_po_parser', '~> 1.1.2', require: false
end
group :test do
@ -395,7 +397,7 @@ group :ed25519 do
end
# Gitaly GRPC client
gem 'gitaly-proto', '~> 0.31.0', require: 'gitaly'
gem 'gitaly-proto', '~> 0.32.0', require: 'gitaly'
gem 'toml-rb', '~> 0.3.15', require: false

View File

@ -276,7 +276,7 @@ GEM
po_to_json (>= 1.0.0)
rails (>= 3.2.0)
gherkin-ruby (0.3.2)
gitaly-proto (0.31.0)
gitaly-proto (0.32.0)
google-protobuf (~> 3.1)
grpc (~> 1.0)
github-linguist (4.7.6)
@ -720,7 +720,7 @@ GEM
retriable (1.4.1)
rinku (2.0.0)
rotp (2.1.2)
rouge (2.2.0)
rouge (2.2.1)
rqrcode (0.7.0)
chunky_png
rqrcode-rails3 (0.1.7)
@ -833,6 +833,7 @@ GEM
faraday (~> 0.9)
jwt (~> 1.5)
multi_json (~> 1.10)
simple_po_parser (1.1.2)
simplecov (0.14.1)
docile (~> 1.1.0)
json (>= 1.8, < 3)
@ -1016,7 +1017,7 @@ DEPENDENCIES
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.2.0)
gitaly-proto (~> 0.31.0)
gitaly-proto (~> 0.32.0)
github-linguist (~> 4.7.0)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-markup (~> 1.5.1)
@ -1142,6 +1143,7 @@ DEPENDENCIES
sidekiq (~> 5.0)
sidekiq-cron (~> 0.6.0)
sidekiq-limit_fetch (~> 3.4)
simple_po_parser (~> 1.1.2)
simplecov (~> 0.14.0)
slack-notifier (~> 1.5.1)
spinach-rails (~> 0.2.1)

View File

@ -2,6 +2,7 @@
[![Build status](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/build.svg)](https://gitlab.com/gitlab-org/gitlab-ce/commits/master)
[![Overall test coverage](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg)](https://gitlab.com/gitlab-org/gitlab-ce/pipelines)
[![Dependency Status](https://gemnasium.com/gitlabhq/gitlabhq.svg)](https://gemnasium.com/gitlabhq/gitlabhq)
[![Code Climate](https://codeclimate.com/github/gitlabhq/gitlabhq.svg)](https://codeclimate.com/github/gitlabhq/gitlabhq)
[![Core Infrastructure Initiative Best Practices](https://bestpractices.coreinfrastructure.org/projects/42/badge)](https://bestpractices.coreinfrastructure.org/projects/42)
[![Gitter](https://badges.gitter.im/gitlabhq/gitlabhq.svg)](https://gitter.im/gitlabhq/gitlabhq?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)

View File

@ -5,7 +5,7 @@ const Api = {
groupPath: '/api/:version/groups/:id.json',
namespacesPath: '/api/:version/namespaces.json',
groupProjectsPath: '/api/:version/groups/:id/projects.json',
projectsPath: '/api/:version/projects.json?simple=true',
projectsPath: '/api/:version/projects.json',
labelsPath: '/:namespace_path/:project_path/labels',
licensePath: '/api/:version/templates/licenses/:key',
gitignorePath: '/api/:version/templates/gitignores/:key',
@ -58,6 +58,7 @@ const Api = {
const defaults = {
search: query,
per_page: 20,
simple: true,
};
if (gon.current_user_id) {

View File

@ -2,17 +2,17 @@
import AccessorUtilities from './lib/utils/accessor';
window.Autosave = (function() {
function Autosave(field, key) {
function Autosave(field, key, resource) {
this.field = field;
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
this.resource = resource;
if (key.join != null) {
key = key.join("/");
key = key.join('/');
}
this.key = "autosave/" + key;
this.field.data("autosave", this);
this.key = 'autosave/' + key;
this.field.data('autosave', this);
this.restore();
this.field.on("input", (function(_this) {
this.field.on('input', (function(_this) {
return function() {
return _this.save();
};
@ -29,7 +29,17 @@ window.Autosave = (function() {
if ((text != null ? text.length : void 0) > 0) {
this.field.val(text);
}
return this.field.trigger("input");
if (!this.resource && this.resource !== 'issue') {
this.field.trigger('input');
} else {
// v-model does not update with jQuery trigger
// https://github.com/vuejs/vue/issues/2804#issuecomment-216968137
const event = new Event('change', { bubbles: true, cancelable: false });
const field = this.field.get(0);
if (field) {
field.dispatchEvent(event);
}
}
};
Autosave.prototype.save = function() {

View File

@ -109,6 +109,7 @@ class AwardsHandler {
}
$thumbsBtn.toggleClass('disabled', $userAuthored);
$thumbsBtn.prop('disabled', $userAuthored);
}
// Create the emoji menu with the first category of emojis.
@ -234,14 +235,33 @@ class AwardsHandler {
}
addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) {
const isMainAwardsBlock = votesBlock.closest('.js-issue-note-awards').length;
if (gl.utils.isInIssuePage() && !isMainAwardsBlock) {
const id = votesBlock.attr('id').replace('note_', '');
$('.emoji-menu').removeClass('is-visible');
$('.js-add-award.is-active').removeClass('is-active');
const toggleAwardEvent = new CustomEvent('toggleAward', {
detail: {
awardName: emoji,
noteId: id,
},
});
document.querySelector('.js-vue-notes-event').dispatchEvent(toggleAwardEvent);
}
const normalizedEmoji = this.emoji.normalizeEmojiName(emoji);
const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent();
this.postEmoji($emojiButton, awardUrl, normalizedEmoji, () => {
this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality);
return typeof callback === 'function' ? callback() : undefined;
});
$('.emoji-menu').removeClass('is-visible');
$('.js-add-award.is-active').removeClass('is-active');
return $('.js-add-award.is-active').removeClass('is-active');
}
addAwardToEmojiBar(votesBlock, emoji, checkForMutuality) {
@ -268,6 +288,14 @@ class AwardsHandler {
}
getVotesBlock() {
if (gl.utils.isInIssuePage()) {
const $el = $('.js-add-award.is-active').closest('.note.timeline-entry');
if ($el.length) {
return $el;
}
}
const currentBlock = $('.js-awards-block.current');
let resultantVotesBlock = currentBlock;
if (currentBlock.length === 0) {

View File

@ -44,7 +44,10 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => {
if (!$submitButton.attr('disabled')) {
$submitButton.trigger('click', [e]);
$submitButton.disable();
if (!gl.utils.isInIssuePage()) {
$submitButton.disable();
}
}
});

View File

@ -2,3 +2,4 @@ import 'underscore';
import './polyfills';
import './jquery';
import './bootstrap';
import './vue';

View File

@ -1,5 +1,4 @@
import Vue from 'vue';
import './vue_resource_interceptor';
if (process.env.NODE_ENV !== 'production') {
Vue.config.productionTip = false;

View File

@ -21,11 +21,13 @@ const DiffNoteAvatars = Vue.extend({
},
template: `
<div class="diff-comment-avatar-holders"
:class="discussionClassName"
v-show="notesCount !== 0">
<div v-if="!isVisible">
<!-- FIXME: Pass an alt attribute here for accessibility -->
<user-avatar-image
v-for="note in notesSubset"
:key="note.id"
class="diff-comment-avatar js-diff-comment-avatar"
@click.native="clickedAvatar($event)"
:img-src="note.authorAvatar"
@ -68,7 +70,8 @@ const DiffNoteAvatars = Vue.extend({
});
});
},
destroyed() {
beforeDestroy() {
this.addNoCommentClass();
$(document).off('toggle.comments');
},
watch: {
@ -85,6 +88,9 @@ const DiffNoteAvatars = Vue.extend({
},
},
computed: {
discussionClassName() {
return `js-diff-avatars-${this.discussionId}`;
},
notesSubset() {
let notes = [];

View File

@ -32,6 +32,10 @@ $(() => {
const tmpApp = new tmp().$mount();
$(this).replaceWith(tmpApp.$el);
$(tmpApp.$el).one('remove.vue', () => {
tmpApp.$destroy();
tmpApp.$el.remove();
});
});
const $components = $(COMPONENT_SELECTOR).filter(function () {

View File

@ -99,7 +99,7 @@ import initChangesDropdown from './init_changes_dropdown';
path = page.split(':');
shortcut_handler = null;
$('.js-gfm-input').each((i, el) => {
$('.js-gfm-input:not(.js-vue-textarea)').each((i, el) => {
const gfm = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources);
const enableGFM = gl.utils.convertPermissionToBoolean(el.dataset.supportsAutocomplete);
gfm.setup($(el), {
@ -172,7 +172,6 @@ import initChangesDropdown from './init_changes_dropdown';
shortcut_handler = new ShortcutsIssuable();
new ZenMode();
initIssuableSidebar();
initNotes();
break;
case 'dashboard:milestones:index':
new ProjectSelect();

View File

@ -128,7 +128,7 @@ window.DropzoneInput = (function() {
// removeAllFiles(true) stops uploading files (if any)
// and remove them from dropzone files queue.
$cancelButton.on('click', (e) => {
const target = e.target.closest('form').querySelector('.div-dropzone');
const target = e.target.closest('.js-main-target-form').querySelector('.div-dropzone');
e.preventDefault();
e.stopPropagation();
@ -140,7 +140,7 @@ window.DropzoneInput = (function() {
// and add that files to the dropzone files queue again.
// addFile() adds file to dropzone files queue and upload it.
$retryLink.on('click', (e) => {
const dropzoneInstance = Dropzone.forElement(e.target.closest('form').querySelector('.div-dropzone'));
const dropzoneInstance = Dropzone.forElement(e.target.closest('.js-main-target-form').querySelector('.div-dropzone'));
const failedFiles = dropzoneInstance.files;
e.preventDefault();

View File

@ -0,0 +1,61 @@
import Cookies from 'js-cookie';
import _ from 'underscore';
import {
getCookieName,
getSelector,
hidePopover,
setupDismissButton,
mouseenter,
mouseleave,
} from './feature_highlight_helper';
export const setupFeatureHighlightPopover = (id, debounceTimeout = 300) => {
const $selector = $(getSelector(id));
const $parent = $selector.parent();
const $popoverContent = $parent.siblings('.feature-highlight-popover-content');
const hideOnScroll = hidePopover.bind($selector);
const debouncedMouseleave = _.debounce(mouseleave, debounceTimeout);
$selector
// Setup popover
.data('content', $popoverContent.prop('outerHTML'))
.popover({
html: true,
// Override the existing template to add custom CSS classes
template: `
<div class="popover feature-highlight-popover" role="tooltip">
<div class="arrow"></div>
<div class="popover-content"></div>
</div>
`,
})
.on('mouseenter', mouseenter)
.on('mouseleave', debouncedMouseleave)
.on('inserted.bs.popover', setupDismissButton)
.on('show.bs.popover', () => {
window.addEventListener('scroll', hideOnScroll);
})
.on('hide.bs.popover', () => {
window.removeEventListener('scroll', hideOnScroll);
})
// Display feature highlight
.removeAttr('disabled');
};
export const shouldHighlightFeature = (id) => {
const element = document.querySelector(getSelector(id));
const previouslyDismissed = Cookies.get(getCookieName(id)) === 'true';
return element && !previouslyDismissed;
};
export const highlightFeatures = (highlightOrder) => {
const featureId = highlightOrder.find(shouldHighlightFeature);
if (featureId) {
setupFeatureHighlightPopover(featureId);
return true;
}
return false;
};

View File

@ -0,0 +1,57 @@
import Cookies from 'js-cookie';
export const getCookieName = cookieId => `feature-highlighted-${cookieId}`;
export const getSelector = highlightId => `.js-feature-highlight[data-highlight=${highlightId}]`;
export const showPopover = function showPopover() {
if (this.hasClass('js-popover-show')) {
return false;
}
this.popover('show');
this.addClass('disable-animation js-popover-show');
return true;
};
export const hidePopover = function hidePopover() {
if (!this.hasClass('js-popover-show')) {
return false;
}
this.popover('hide');
this.removeClass('disable-animation js-popover-show');
return true;
};
export const dismiss = function dismiss(cookieId) {
Cookies.set(getCookieName(cookieId), true);
hidePopover.call(this);
this.hide();
};
export const mouseleave = function mouseleave() {
if (!$('.popover:hover').length > 0) {
const $featureHighlight = $(this);
hidePopover.call($featureHighlight);
}
};
export const mouseenter = function mouseenter() {
const $featureHighlight = $(this);
const showedPopover = showPopover.call($featureHighlight);
if (showedPopover) {
$('.popover')
.on('mouseleave', mouseleave.bind($featureHighlight));
}
};
export const setupDismissButton = function setupDismissButton() {
const popoverId = this.getAttribute('aria-describedby');
const cookieId = this.dataset.highlight;
const $popover = $(this);
const dismissWrapper = dismiss.bind($popover, cookieId);
$(`#${popoverId} .dismiss-feature-highlight`)
.on('click', dismissWrapper);
};

View File

@ -0,0 +1,12 @@
import { highlightFeatures } from './feature_highlight';
import bp from '../breakpoints';
const highlightOrder = ['issue-boards'];
export default function domContentLoaded(order) {
if (bp.getBreakpointSize() === 'lg') {
highlightFeatures(order);
}
}
document.addEventListener('DOMContentLoaded', domContentLoaded.bind(this, highlightOrder));

View File

@ -12,6 +12,7 @@ let sidebar;
export const mousePos = [];
export const setSidebar = (el) => { sidebar = el; };
export const getOpenMenu = () => currentOpenMenu;
export const setOpenMenu = (menu = null) => { currentOpenMenu = menu; };
export const slope = (a, b) => (b.y - a.y) / (b.x - a.x);
@ -141,6 +142,14 @@ export const documentMouseMove = (e) => {
if (mousePos.length > 6) mousePos.shift();
};
export const subItemsMouseLeave = (relatedTarget) => {
clearTimeout(timeoutId);
if (!relatedTarget.closest(`.${IS_OVER_CLASS}`)) {
hideMenu(currentOpenMenu);
}
};
export default () => {
sidebar = document.querySelector('.nav-sidebar');
@ -162,10 +171,7 @@ export default () => {
const subItems = el.querySelector('.sidebar-sub-level-items');
if (subItems) {
subItems.addEventListener('mouseleave', () => {
clearTimeout(timeoutId);
hideMenu(currentOpenMenu);
});
subItems.addEventListener('mouseleave', e => subItemsMouseLeave(e.relatedTarget));
}
el.addEventListener('mouseenter', e => mouseEnterTopItems(e.currentTarget));

View File

@ -486,7 +486,7 @@ GitLabDropdown = (function() {
GitLabDropdown.prototype.shouldPropagate = function(e) {
var $target;
if (this.options.multiSelect) {
if (this.options.multiSelect || this.options.shouldPropagate === false) {
$target = $(e.target);
if ($target && !$target.hasClass('dropdown-menu-close') &&
!$target.hasClass('dropdown-menu-close-icon') &&
@ -546,10 +546,10 @@ GitLabDropdown = (function() {
};
GitLabDropdown.prototype.positionMenuAbove = function() {
var $button = $(this.el);
var $menu = this.dropdown.find('.dropdown-menu');
$menu.css('top', ($button.height() + $menu.height()) * -1);
$menu.css('top', 'initial');
$menu.css('bottom', '100%');
};
GitLabDropdown.prototype.hidden = function(e) {
@ -698,7 +698,7 @@ GitLabDropdown = (function() {
GitLabDropdown.prototype.noResults = function() {
var html;
return html = "<li class='dropdown-menu-empty-link'> <a href='#' class='is-focused'> No matching results. </a> </li>";
return html = '<li class="dropdown-menu-empty-link"><a href="#" class="is-focused">No matching results</a></li>';
};
GitLabDropdown.prototype.rowClicked = function(el) {

View File

@ -10,8 +10,6 @@ import ZenMode from './zen_mode';
(function() {
this.IssuableForm = (function() {
IssuableForm.prototype.issueMoveConfirmMsg = 'Are you sure you want to move this issue to another project?';
IssuableForm.prototype.wipRegex = /^\s*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i;
function IssuableForm(form) {
@ -26,7 +24,6 @@ import ZenMode from './zen_mode';
new ZenMode();
this.titleField = this.form.find("input[name*='[title]']");
this.descriptionField = this.form.find("textarea[name*='[description]']");
this.issueMoveField = this.form.find("#move_to_project_id");
if (!(this.titleField.length && this.descriptionField.length)) {
return;
}
@ -34,7 +31,6 @@ import ZenMode from './zen_mode';
this.form.on("submit", this.handleSubmit);
this.form.on("click", ".btn-cancel", this.resetAutosave);
this.initWip();
this.initMoveDropdown();
$issuableDueDate = $('#issuable-due-date');
if ($issuableDueDate.length) {
calendar = new Pikaday({
@ -56,12 +52,6 @@ import ZenMode from './zen_mode';
};
IssuableForm.prototype.handleSubmit = function() {
var fieldId = (this.issueMoveField != null) ? this.issueMoveField.val() : null;
if ((parseInt(fieldId, 10) || 0) > 0) {
if (!confirm(this.issueMoveConfirmMsg)) {
return false;
}
}
return this.resetAutosave();
};
@ -113,48 +103,6 @@ import ZenMode from './zen_mode';
return this.titleField.val("WIP: " + (this.titleField.val()));
};
IssuableForm.prototype.initMoveDropdown = function() {
var $moveDropdown, pageSize;
$moveDropdown = $('.js-move-dropdown');
if ($moveDropdown.length) {
pageSize = $moveDropdown.data('page-size');
return $('.js-move-dropdown').select2({
ajax: {
url: $moveDropdown.data('projects-url'),
quietMillis: 125,
data: function(term, page, context) {
return {
search: term,
offset_id: context
};
},
results: function(data) {
var context,
more;
if (data.length >= pageSize)
more = true;
if (data[data.length - 1])
context = data[data.length - 1].id;
return {
results: data,
more: more,
context: context
};
}
},
formatResult: function(project) {
return project.name_with_namespace;
},
formatSelection: function(project) {
return project.name_with_namespace;
}
});
}
};
return IssuableForm;
})();
}).call(window);

View File

@ -42,7 +42,7 @@ class Issue {
initIssueBtnEventListeners() {
const issueFailMessage = 'Unable to update this issue at this time.';
return $(document).on('click', 'a.btn-close, a.btn-reopen', (e) => {
return $(document).on('click', '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen', (e) => {
var $button, shouldSubmit, url;
e.preventDefault();
e.stopImmediatePropagation();
@ -66,12 +66,11 @@ class Issue {
const projectIssuesCounter = $('.issue_counter');
if ('id' in data) {
$(document).trigger('issuable:change');
const isClosed = $button.hasClass('btn-close');
isClosedBadge.toggleClass('hidden', !isClosed);
isOpenBadge.toggleClass('hidden', isClosed);
$(document).trigger('issuable:change', isClosed);
this.toggleCloseReopenButton(isClosed);
let numProjectIssues = Number(projectIssuesCounter.text().replace(/[^\d]/, ''));
@ -121,7 +120,7 @@ class Issue {
static submitNoteForm(form) {
var noteText;
noteText = form.find("textarea.js-note-text").val();
if (noteText.trim().length > 0) {
if (noteText && noteText.trim().length > 0) {
return form.submit();
}
}

View File

@ -17,10 +17,6 @@ export default {
required: true,
type: String,
},
canMove: {
required: true,
type: Boolean,
},
canUpdate: {
required: true,
type: Boolean,
@ -80,11 +76,11 @@ export default {
type: Boolean,
required: true,
},
markdownPreviewUrl: {
markdownPreviewPath: {
type: String,
required: true,
},
markdownDocs: {
markdownDocsPath: {
type: String,
required: true,
},
@ -96,10 +92,6 @@ export default {
type: String,
required: true,
},
projectsAutocompleteUrl: {
type: String,
required: true,
},
},
data() {
const store = new Store({
@ -142,7 +134,6 @@ export default {
confidential: this.isConfidential,
description: this.state.descriptionText,
lockedWarningVisible: false,
move_to_project_id: 0,
updateLoading: false,
});
}
@ -151,16 +142,6 @@ export default {
this.showForm = false;
},
updateIssuable() {
const canPostUpdate = this.store.formState.move_to_project_id !== 0 ?
confirm('Are you sure you want to move this issue to another project?') : true; // eslint-disable-line no-alert
if (!canPostUpdate) {
this.store.setFormState({
updateLoading: false,
});
return;
}
this.service.updateIssuable(this.store.formState)
.then(res => res.json())
.then((data) => {
@ -239,14 +220,12 @@ export default {
<form-component
v-if="canUpdate && showForm"
:form-state="formState"
:can-move="canMove"
:can-destroy="canDestroy"
:issuable-templates="issuableTemplates"
:markdown-docs="markdownDocs"
:markdown-preview-url="markdownPreviewUrl"
:markdown-docs-path="markdownDocsPath"
:markdown-preview-path="markdownPreviewPath"
:project-path="projectPath"
:project-namespace="projectNamespace"
:projects-autocomplete-url="projectsAutocompleteUrl"
/>
<div v-else>
<title-component

View File

@ -10,11 +10,11 @@
type: Object,
required: true,
},
markdownPreviewUrl: {
markdownPreviewPath: {
type: String,
required: true,
},
markdownDocs: {
markdownDocsPath: {
type: String,
required: true,
},
@ -36,8 +36,8 @@
Description
</label>
<markdown-field
:markdown-preview-url="markdownPreviewUrl"
:markdown-docs="markdownDocs">
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath">
<textarea
id="issue-description"
class="note-textarea js-gfm-input js-autosize markdown-area"

View File

@ -1,83 +0,0 @@
<script>
import tooltip from '../../../vue_shared/directives/tooltip';
export default {
directives: {
tooltip,
},
props: {
formState: {
type: Object,
required: true,
},
projectsAutocompleteUrl: {
type: String,
required: true,
},
},
mounted() {
const $moveDropdown = $(this.$refs['move-dropdown']);
$moveDropdown.select2({
ajax: {
url: this.projectsAutocompleteUrl,
quietMillis: 125,
data(term, page, context) {
return {
search: term,
offset_id: context,
};
},
results(data) {
const more = data.length >= 50;
const context = data[data.length - 1] ? data[data.length - 1].id : null;
return {
results: data,
more,
context,
};
},
},
formatResult(project) {
return project.name_with_namespace;
},
formatSelection(project) {
return project.name_with_namespace;
},
})
.on('change', (e) => {
this.formState.move_to_project_id = parseInt(e.target.value, 10);
});
},
beforeDestroy() {
$(this.$refs['move-dropdown']).select2('destroy');
},
};
</script>
<template>
<fieldset>
<label
for="issuable-move"
class="sr-only">
Move
</label>
<div class="issuable-form-select-holder append-right-5">
<input
ref="move-dropdown"
type="hidden"
id="issuable-move"
data-placeholder="Move to a different project" />
</div>
<span
v-tooltip
data-placement="auto top"
title="Moving an issue will copy the discussion to a different project and close it here. All participants will be notified of the new location.">
<i
class="fa fa-question-circle"
aria-hidden="true">
</i>
</span>
</fieldset>
</template>

View File

@ -4,15 +4,10 @@
import descriptionField from './fields/description.vue';
import editActions from './edit_actions.vue';
import descriptionTemplate from './fields/description_template.vue';
import projectMove from './fields/project_move.vue';
import confidentialCheckbox from './fields/confidential_checkbox.vue';
export default {
props: {
canMove: {
type: Boolean,
required: true,
},
canDestroy: {
type: Boolean,
required: true,
@ -26,11 +21,11 @@
required: false,
default: () => [],
},
markdownPreviewUrl: {
markdownPreviewPath: {
type: String,
required: true,
},
markdownDocs: {
markdownDocsPath: {
type: String,
required: true,
},
@ -42,10 +37,6 @@
type: String,
required: true,
},
projectsAutocompleteUrl: {
type: String,
required: true,
},
},
components: {
lockedWarning,
@ -53,7 +44,6 @@
descriptionField,
descriptionTemplate,
editActions,
projectMove,
confidentialCheckbox,
},
computed: {
@ -89,14 +79,10 @@
</div>
<description-field
:form-state="formState"
:markdown-preview-url="markdownPreviewUrl"
:markdown-docs="markdownDocs" />
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath" />
<confidential-checkbox
:form-state="formState" />
<project-move
v-if="canMove"
:form-state="formState"
:projects-autocomplete-url="projectsAutocompleteUrl" />
<edit-actions
:form-state="formState"
:can-destroy="canDestroy" />

View File

@ -28,7 +28,6 @@ document.addEventListener('DOMContentLoaded', () => {
props: {
canUpdate: this.canUpdate,
canDestroy: this.canDestroy,
canMove: this.canMove,
endpoint: this.endpoint,
issuableRef: this.issuableRef,
initialTitleHtml: this.initialTitleHtml,
@ -37,11 +36,10 @@ document.addEventListener('DOMContentLoaded', () => {
initialDescriptionText: this.initialDescriptionText,
issuableTemplates: this.issuableTemplates,
isConfidential: this.isConfidential,
markdownPreviewUrl: this.markdownPreviewUrl,
markdownDocs: this.markdownDocs,
markdownPreviewPath: this.markdownPreviewPath,
markdownDocsPath: this.markdownDocsPath,
projectPath: this.projectPath,
projectNamespace: this.projectNamespace,
projectsAutocompleteUrl: this.projectsAutocompleteUrl,
updatedAt: this.updatedAt,
updatedByName: this.updatedByName,
updatedByPath: this.updatedByPath,

View File

@ -6,7 +6,6 @@ export default class Store {
confidential: false,
description: '',
lockedWarningVisible: false,
move_to_project_id: 0,
updateLoading: false,
};
}

View File

@ -27,6 +27,13 @@
}
};
w.gl.utils.isInIssuePage = () => {
const page = gl.utils.getPagePath(1);
const action = gl.utils.getPagePath(2);
return page === 'issues' && action === 'show';
};
w.gl.utils.ajaxGet = function(url) {
return $.ajax({
type: "GET",
@ -167,11 +174,12 @@
};
gl.utils.scrollToElement = function($el) {
var top = $el.offset().top;
gl.mrTabsHeight = gl.mrTabsHeight || $('.merge-request-tabs').height();
const top = $el.offset().top;
const mrTabsHeight = $('.merge-request-tabs').height() || 0;
const headerHeight = $('.navbar-gitlab').height() || 0;
return $('body, html').animate({
scrollTop: top - (gl.mrTabsHeight)
scrollTop: top - mrTabsHeight - headerHeight,
}, 200);
};

View File

@ -102,6 +102,7 @@ import './label_manager';
import './labels';
import './labels_select';
import './layout_nav';
import './feature_highlight/feature_highlight_options';
import LazyLoader from './lazy_loader';
import './line_highlighter';
import './logo';
@ -131,6 +132,7 @@ import './project_new';
import './project_select';
import './project_show';
import './project_variables';
import './projects_dropdown';
import './projects_list';
import './syntax_highlight';
import './render_math';
@ -248,7 +250,10 @@ $(function () {
// Initialize popovers
$body.popover({
selector: '[data-toggle="popover"]',
trigger: 'focus'
trigger: 'focus',
// set the viewport to the main content, excluding the navigation bar, so
// the navigation can't overlap the popover
viewport: '.page-with-sidebar'
});
$('.trigger-submit').on('change', function () {
return $(this).parents('form').submit();

View File

@ -3,11 +3,12 @@
import GraphLegend from './graph/legend.vue';
import GraphFlag from './graph/flag.vue';
import GraphDeployment from './graph/deployment.vue';
import monitoringPaths from './monitoring_paths.vue';
import MonitoringMixin from '../mixins/monitoring_mixins';
import eventHub from '../event_hub';
import measurements from '../utils/measurements';
import { formatRelevantDigits } from '../../lib/utils/number_utils';
import { timeScaleFormat } from '../utils/date_time_formatters';
import createTimeSeries from '../utils/multiple_time_series';
import bp from '../../breakpoints';
const bisectDate = d3.bisector(d => d.time).left;
@ -36,32 +37,29 @@
data() {
return {
baseGraphHeight: 450,
baseGraphWidth: 600,
graphHeight: 450,
graphWidth: 600,
graphHeightOffset: 120,
xScale: {},
yScale: {},
margin: {},
data: [],
unitOfDisplay: '',
areaColorRgb: '#8fbce8',
lineColorRgb: '#1f78d1',
yAxisLabel: '',
legendTitle: '',
reducedDeploymentData: [],
area: '',
line: '',
measurements: measurements.large,
currentData: {
time: new Date(),
value: 0,
},
currentYCoordinate: 0,
currentDataIndex: 0,
currentXCoordinate: 0,
currentFlagPosition: 0,
metricUsage: '',
showFlag: false,
showDeployInfo: true,
timeSeries: [],
};
},
@ -69,16 +67,17 @@
GraphLegend,
GraphFlag,
GraphDeployment,
monitoringPaths,
},
computed: {
outterViewBox() {
return `0 0 ${this.graphWidth} ${this.graphHeight}`;
return `0 0 ${this.baseGraphWidth} ${this.baseGraphHeight}`;
},
innerViewBox() {
if ((this.graphWidth - 150) > 0) {
return `0 0 ${this.graphWidth - 150} ${this.graphHeight}`;
if ((this.baseGraphWidth - 150) > 0) {
return `0 0 ${this.baseGraphWidth - 150} ${this.baseGraphHeight}`;
}
return '0 0 0 0';
},
@ -89,7 +88,7 @@
paddingBottomRootSvg() {
return {
paddingBottom: `${(Math.ceil(this.graphHeight * 100) / this.graphWidth) || 0}%`,
paddingBottom: `${(Math.ceil(this.baseGraphHeight * 100) / this.baseGraphWidth) || 0}%`,
};
},
},
@ -104,17 +103,16 @@
this.margin = measurements.small.margin;
this.measurements = measurements.small;
}
this.data = query.result[0].values;
this.unitOfDisplay = query.unit || '';
this.yAxisLabel = this.graphData.y_label || 'Values';
this.legendTitle = query.label || 'Average';
this.graphWidth = this.$refs.baseSvg.clientWidth -
this.margin.left - this.margin.right;
this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom;
if (this.data !== undefined) {
this.renderAxesPaths();
this.formatDeployments();
}
this.baseGraphHeight = this.graphHeight;
this.baseGraphWidth = this.graphWidth;
this.renderAxesPaths();
this.formatDeployments();
},
handleMouseOverGraph(e) {
@ -123,16 +121,17 @@
point.y = e.clientY;
point = point.matrixTransform(this.$refs.graphData.getScreenCTM().inverse());
point.x = point.x += 7;
const timeValueOverlay = this.xScale.invert(point.x);
const overlayIndex = bisectDate(this.data, timeValueOverlay, 1);
const d0 = this.data[overlayIndex - 1];
const d1 = this.data[overlayIndex];
const firstTimeSeries = this.timeSeries[0];
const timeValueOverlay = firstTimeSeries.timeSeriesScaleX.invert(point.x);
const overlayIndex = bisectDate(firstTimeSeries.values, timeValueOverlay, 1);
const d0 = firstTimeSeries.values[overlayIndex - 1];
const d1 = firstTimeSeries.values[overlayIndex];
if (d0 === undefined || d1 === undefined) return;
const evalTime = timeValueOverlay - d0[0] > d1[0] - timeValueOverlay;
this.currentData = evalTime ? d1 : d0;
this.currentXCoordinate = Math.floor(this.xScale(this.currentData.time));
this.currentDataIndex = evalTime ? overlayIndex : (overlayIndex - 1);
this.currentXCoordinate = Math.floor(firstTimeSeries.timeSeriesScaleX(this.currentData.time));
const currentDeployXPos = this.mouseOverDeployInfo(point.x);
this.currentYCoordinate = this.yScale(this.currentData.value);
if (this.currentXCoordinate > (this.graphWidth - 200)) {
this.currentFlagPosition = this.currentXCoordinate - 103;
@ -145,17 +144,25 @@
} else {
this.showFlag = true;
}
this.metricUsage = `${formatRelevantDigits(this.currentData.value)} ${this.unitOfDisplay}`;
},
renderAxesPaths() {
this.timeSeries = createTimeSeries(this.graphData.queries[0].result,
this.graphWidth,
this.graphHeight,
this.graphHeightOffset);
if (this.timeSeries.length > 3) {
this.baseGraphHeight = this.baseGraphHeight += (this.timeSeries.length - 3) * 20;
}
const axisXScale = d3.time.scale()
.range([0, this.graphWidth]);
this.yScale = d3.scale.linear()
const axisYScale = d3.scale.linear()
.range([this.graphHeight - this.graphHeightOffset, 0]);
axisXScale.domain(d3.extent(this.data, d => d.time));
this.yScale.domain([0, d3.max(this.data.map(d => d.value))]);
axisXScale.domain(d3.extent(this.timeSeries[0].values, d => d.time));
axisYScale.domain([0, d3.max(this.timeSeries[0].values.map(d => d.value))]);
const xAxis = d3.svg.axis()
.scale(axisXScale)
@ -164,7 +171,7 @@
.orient('bottom');
const yAxis = d3.svg.axis()
.scale(this.yScale)
.scale(axisYScale)
.ticks(measurements.yTicks)
.orient('left');
@ -180,25 +187,6 @@
.attr('class', 'axis-tick');
} // Avoid adding the class to the first tick, to prevent coloring
}); // This will select all of the ticks once they're rendered
this.xScale = d3.time.scale()
.range([0, this.graphWidth - 70]);
this.xScale.domain(d3.extent(this.data, d => d.time));
const areaFunction = d3.svg.area()
.x(d => this.xScale(d.time))
.y0(this.graphHeight - this.graphHeightOffset)
.y1(d => this.yScale(d.value))
.interpolate('linear');
const lineFunction = d3.svg.line()
.x(d => this.xScale(d.time))
.y(d => this.yScale(d.value));
this.line = lineFunction(this.data);
this.area = areaFunction(this.data);
},
},
@ -245,30 +233,25 @@
:graph-height="graphHeight"
:margin="margin"
:measurements="measurements"
:area-color-rgb="areaColorRgb"
:legend-title="legendTitle"
:y-axis-label="yAxisLabel"
:metric-usage="metricUsage"
:time-series="timeSeries"
:unit-of-display="unitOfDisplay"
:current-data-index="currentDataIndex"
/>
<svg
class="graph-data"
:viewBox="innerViewBox"
ref="graphData">
<path
class="metric-area"
:d="area"
:fill="areaColorRgb"
transform="translate(-5, 20)">
</path>
<path
class="metric-line"
:d="line"
:stroke="lineColorRgb"
fill="none"
stroke-width="2"
transform="translate(-5, 20)">
</path>
<graph-deployment
<monitoring-paths
v-for="(path, index) in timeSeries"
:key="index"
:generated-line-path="path.linePath"
:generated-area-path="path.areaPath"
:line-color="path.lineColor"
:area-color="path.areaColor"
/>
<monitoring-deployment
:show-deploy-info="showDeployInfo"
:deployment-data="reducedDeploymentData"
:graph-height="graphHeight"
@ -277,7 +260,6 @@
<graph-flag
v-if="showFlag"
:current-x-coordinate="currentXCoordinate"
:current-y-coordinate="currentYCoordinate"
:current-data="currentData"
:current-flag-position="currentFlagPosition"
:graph-height="graphHeight"

View File

@ -7,10 +7,6 @@
type: Number,
required: true,
},
currentYCoordinate: {
type: Number,
required: true,
},
currentFlagPosition: {
type: Number,
required: true,
@ -60,16 +56,7 @@
:y2="calculatedHeight"
transform="translate(-5, 20)">
</line>
<circle
class="circle-metric"
:fill="circleColorRgb"
stroke="#000"
:cx="currentXCoordinate"
:cy="currentYCoordinate"
r="5"
transform="translate(-5, 20)">
</circle>
<svg
<svg
class="rect-text-metric"
:x="currentFlagPosition"
y="0">

View File

@ -1,4 +1,6 @@
<script>
import { formatRelevantDigits } from '../../../lib/utils/number_utils';
export default {
props: {
graphWidth: {
@ -17,10 +19,6 @@
type: Object,
required: true,
},
areaColorRgb: {
type: String,
required: true,
},
legendTitle: {
type: String,
required: true,
@ -29,15 +27,25 @@
type: String,
required: true,
},
metricUsage: {
timeSeries: {
type: Array,
required: true,
},
unitOfDisplay: {
type: String,
required: true,
},
currentDataIndex: {
type: Number,
required: true,
},
},
data() {
return {
yLabelWidth: 0,
yLabelHeight: 0,
seriesXPosition: 0,
metricUsageXPosition: 0,
};
},
computed: {
@ -63,10 +71,28 @@
yPosition() {
return ((this.graphHeight - this.margin.top) + this.measurements.axisLabelLineOffset) || 0;
},
},
methods: {
translateLegendGroup(index) {
return `translate(0, ${12 * (index)})`;
},
formatMetricUsage(series) {
return `${formatRelevantDigits(series.values[this.currentDataIndex].value)} ${this.unitOfDisplay}`;
},
},
mounted() {
this.$nextTick(() => {
const bbox = this.$refs.ylabel.getBBox();
this.metricUsageXPosition = 0;
this.seriesXPosition = 0;
if (this.$refs.legendTitleSvg != null) {
this.seriesXPosition = this.$refs.legendTitleSvg[0].getBBox().width;
}
if (this.$refs.seriesTitleSvg != null) {
this.metricUsageXPosition = this.$refs.seriesTitleSvg[0].getBBox().width;
}
this.yLabelWidth = bbox.width + 10; // Added some padding
this.yLabelHeight = bbox.height + 5;
});
@ -121,24 +147,33 @@
dy=".35em">
Time
</text>
<rect
:fill="areaColorRgb"
:width="measurements.legends.width"
:height="measurements.legends.height"
x="20"
:y="graphHeight - measurements.legendOffset">
</rect>
<text
class="text-metric-title"
x="50"
:y="graphHeight - 25">
{{legendTitle}}
</text>
<text
class="text-metric-usage"
x="50"
:y="graphHeight - 10">
{{metricUsage}}
</text>
<g class="legend-group"
v-for="(series, index) in timeSeries"
:key="index"
:transform="translateLegendGroup(index)">
<rect
:fill="series.areaColor"
:width="measurements.legends.width"
:height="measurements.legends.height"
x="20"
:y="graphHeight - measurements.legendOffset">
</rect>
<text
v-if="timeSeries.length > 1"
class="legend-metric-title"
ref="legendTitleSvg"
x="38"
:y="graphHeight - 30">
{{legendTitle}} Series {{index + 1}} {{formatMetricUsage(series)}}
</text>
<text
v-else
class="legend-metric-title"
ref="legendTitleSvg"
x="38"
:y="graphHeight - 30">
{{legendTitle}} {{formatMetricUsage(series)}}
</text>
</g>
</g>
</template>

View File

@ -0,0 +1,40 @@
<script>
export default {
props: {
generatedLinePath: {
type: String,
required: true,
},
generatedAreaPath: {
type: String,
required: true,
},
lineColor: {
type: String,
required: true,
},
areaColor: {
type: String,
required: true,
},
},
};
</script>
<template>
<g>
<path
class="metric-area"
:d="generatedAreaPath"
:fill="areaColor"
transform="translate(-5, 20)">
</path>
<path
class="metric-line"
:d="generatedLinePath"
:stroke="lineColor"
fill="none"
stroke-width="1"
transform="translate(-5, 20)">
</path>
</g>
</template>

View File

@ -21,9 +21,9 @@ const mixins = {
formatDeployments() {
this.reducedDeploymentData = this.deploymentData.reduce((deploymentDataArray, deployment) => {
const time = new Date(deployment.created_at);
const xPos = Math.floor(this.xScale(time));
const xPos = Math.floor(this.timeSeries[0].timeSeriesScaleX(time));
time.setSeconds(this.data[0].time.getSeconds());
time.setSeconds(this.timeSeries[0].values[0].time.getSeconds());
if (xPos >= 0) {
deploymentDataArray.push({

View File

@ -1,46 +1,52 @@
import _ from 'underscore';
class MonitoringStore {
function sortMetrics(metrics) {
return _.chain(metrics).sortBy('weight').sortBy('title').value();
}
function normalizeMetrics(metrics) {
return metrics.map(metric => ({
...metric,
queries: metric.queries.map(query => ({
...query,
result: query.result.map(result => ({
...result,
values: result.values.map(([timestamp, value]) => ({
time: new Date(timestamp * 1000),
value,
})),
})),
})),
}));
}
function collate(array, rows = 2) {
const collatedArray = [];
let row = [];
array.forEach((value, index) => {
row.push(value);
if ((index + 1) % rows === 0) {
collatedArray.push(row);
row = [];
}
});
if (row.length > 0) {
collatedArray.push(row);
}
return collatedArray;
}
export default class MonitoringStore {
constructor() {
this.groups = [];
this.deploymentData = [];
}
// eslint-disable-next-line class-methods-use-this
createArrayRows(metrics = []) {
const currentMetrics = metrics;
const availableMetrics = [];
let metricsRow = [];
let index = 1;
Object.keys(currentMetrics).forEach((key) => {
const metricValues = currentMetrics[key].queries[0].result[0].values;
if (metricValues != null) {
const literalMetrics = metricValues.map(metric => ({
time: new Date(metric[0] * 1000),
value: metric[1],
}));
currentMetrics[key].queries[0].result[0].values = literalMetrics;
metricsRow.push(currentMetrics[key]);
if (index % 2 === 0) {
availableMetrics.push(metricsRow);
metricsRow = [];
}
index = index += 1;
}
});
if (metricsRow.length > 0) {
availableMetrics.push(metricsRow);
}
return availableMetrics;
}
storeMetrics(groups = []) {
this.groups = groups.map((group) => {
const currentGroup = group;
currentGroup.metrics = _.chain(group.metrics).sortBy('weight').sortBy('title').value();
currentGroup.metrics = this.createArrayRows(currentGroup.metrics);
return currentGroup;
});
this.groups = groups.map(group => ({
...group,
metrics: collate(normalizeMetrics(sortMetrics(group.metrics))),
}));
}
storeDeploymentData(deploymentData = []) {
@ -57,5 +63,3 @@ class MonitoringStore {
return metricsCount;
}
}
export default MonitoringStore;

View File

@ -7,15 +7,15 @@ export default {
left: 40,
},
legends: {
width: 15,
height: 25,
width: 10,
height: 3,
},
backgroundLegend: {
width: 30,
height: 50,
},
axisLabelLineOffset: -20,
legendOffset: 35,
legendOffset: 33,
},
large: { // This covers both md and lg screen sizes
margin: {
@ -25,15 +25,15 @@ export default {
left: 80,
},
legends: {
width: 20,
height: 30,
width: 15,
height: 3,
},
backgroundLegend: {
width: 30,
height: 150,
},
axisLabelLineOffset: 20,
legendOffset: 38,
legendOffset: 36,
},
xTicks: 8,
yTicks: 3,

View File

@ -0,0 +1,80 @@
import d3 from 'd3';
import _ from 'underscore';
export default function createTimeSeries(seriesData, graphWidth, graphHeight, graphHeightOffset) {
const maxValues = seriesData.map((timeSeries, index) => {
const maxValue = d3.max(timeSeries.values.map(d => d.value));
return {
maxValue,
index,
};
});
const maxValueFromSeries = _.max(maxValues, val => val.maxValue);
let timeSeriesNumber = 1;
let lineColor = '#1f78d1';
let areaColor = '#8fbce8';
return seriesData.map((timeSeries) => {
const timeSeriesScaleX = d3.time.scale()
.range([0, graphWidth - 70]);
const timeSeriesScaleY = d3.scale.linear()
.range([graphHeight - graphHeightOffset, 0]);
timeSeriesScaleX.domain(d3.extent(timeSeries.values, d => d.time));
timeSeriesScaleY.domain([0, maxValueFromSeries.maxValue]);
const lineFunction = d3.svg.line()
.x(d => timeSeriesScaleX(d.time))
.y(d => timeSeriesScaleY(d.value));
const areaFunction = d3.svg.area()
.x(d => timeSeriesScaleX(d.time))
.y0(graphHeight - graphHeightOffset)
.y1(d => timeSeriesScaleY(d.value))
.interpolate('linear');
switch (timeSeriesNumber) {
case 1:
lineColor = '#1f78d1';
areaColor = '#8fbce8';
break;
case 2:
lineColor = '#fc9403';
areaColor = '#feca81';
break;
case 3:
lineColor = '#db3b21';
areaColor = '#ed9d90';
break;
case 4:
lineColor = '#1aaa55';
areaColor = '#8dd5aa';
break;
case 5:
lineColor = '#6666c4';
areaColor = '#d1d1f0';
break;
default:
lineColor = '#1f78d1';
areaColor = '#8fbce8';
break;
}
if (timeSeriesNumber <= 5) {
timeSeriesNumber = timeSeriesNumber += 1;
} else {
timeSeriesNumber = 1;
}
return {
linePath: lineFunction(timeSeries.values),
areaPath: areaFunction(timeSeries.values),
timeSeriesScaleX,
values: timeSeries.values,
lineColor,
areaColor,
};
});
}

View File

@ -464,7 +464,6 @@ export default class Notes {
}
renderDiscussionAvatar(diffAvatarContainer, noteEntity) {
var commentButton = diffAvatarContainer.find('.js-add-diff-note-button');
var avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders');
if (!avatarHolder.length) {
@ -475,10 +474,6 @@ export default class Notes {
gl.diffNotesCompileComponents();
}
if (commentButton.length) {
commentButton.remove();
}
}
/**
@ -767,6 +762,7 @@ export default class Notes {
var $note, $notes;
$note = $(el);
$notes = $note.closest('.discussion-notes');
const discussionId = $('.notes', $notes).data('discussion-id');
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
if (gl.diffNoteApps[noteElId]) {
@ -783,6 +779,8 @@ export default class Notes {
// "Discussions" tab
$notes.closest('.timeline-entry').remove();
$(`.js-diff-avatars-${discussionId}`).trigger('remove.vue');
// The notes tr can contain multiple lists of notes, like on the parallel diff
if (notesTr.find('.discussion-notes').length > 1) {
$notes.remove();

View File

@ -0,0 +1,347 @@
<script>
/* global Flash, Autosave */
import { mapActions, mapGetters } from 'vuex';
import _ from 'underscore';
import '../../autosave';
import TaskList from '../../task_list';
import * as constants from '../constants';
import eventHub from '../event_hub';
import confidentialIssue from '../../vue_shared/components/issue/confidential_issue_warning.vue';
import issueNoteSignedOutWidget from './issue_note_signed_out_widget.vue';
import markdownField from '../../vue_shared/components/markdown/field.vue';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
export default {
name: 'issueCommentForm',
data() {
return {
note: '',
noteType: constants.COMMENT,
// Can't use mapGetters,
// this needs to be in the data object because it belongs to the state
issueState: this.$store.getters.getIssueData.state,
isSubmitting: false,
isSubmitButtonDisabled: true,
};
},
components: {
confidentialIssue,
issueNoteSignedOutWidget,
markdownField,
userAvatarLink,
},
watch: {
note(newNote) {
this.setIsSubmitButtonDisabled(newNote, this.isSubmitting);
},
isSubmitting(newValue) {
this.setIsSubmitButtonDisabled(this.note, newValue);
},
},
computed: {
...mapGetters([
'getCurrentUserLastNote',
'getUserData',
'getIssueData',
'getNotesData',
]),
isLoggedIn() {
return this.getUserData.id;
},
commentButtonTitle() {
return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion';
},
isIssueOpen() {
return this.issueState === constants.OPENED || this.issueState === constants.REOPENED;
},
issueActionButtonTitle() {
if (this.note.length) {
const actionText = this.isIssueOpen ? 'close' : 'reopen';
return this.noteType === constants.COMMENT ? `Comment & ${actionText} issue` : `Start discussion & ${actionText} issue`;
}
return this.isIssueOpen ? 'Close issue' : 'Reopen issue';
},
actionButtonClassNames() {
return {
'btn-reopen': !this.isIssueOpen,
'btn-close': this.isIssueOpen,
'js-note-target-close': this.isIssueOpen,
'js-note-target-reopen': !this.isIssueOpen,
};
},
markdownDocsPath() {
return this.getNotesData.markdownDocsPath;
},
quickActionsDocsPath() {
return this.getNotesData.quickActionsDocsPath;
},
markdownPreviewPath() {
return this.getIssueData.preview_note_path;
},
author() {
return this.getUserData;
},
canUpdateIssue() {
return this.getIssueData.current_user.can_update;
},
endpoint() {
return this.getIssueData.create_note_path;
},
isConfidentialIssue() {
return this.getIssueData.confidential;
},
},
methods: {
...mapActions([
'saveNote',
'removePlaceholderNotes',
]),
setIsSubmitButtonDisabled(note, isSubmitting) {
if (!_.isEmpty(note) && !isSubmitting) {
this.isSubmitButtonDisabled = false;
} else {
this.isSubmitButtonDisabled = true;
}
},
handleSave(withIssueAction) {
if (this.note.length) {
const noteData = {
endpoint: this.endpoint,
flashContainer: this.$el,
data: {
note: {
noteable_type: constants.NOTEABLE_TYPE,
noteable_id: this.getIssueData.id,
note: this.note,
},
},
};
if (this.noteType === constants.DISCUSSION) {
noteData.data.note.type = constants.DISCUSSION_NOTE;
}
this.isSubmitting = true;
this.note = ''; // Empty textarea while being requested. Repopulate in catch
this.saveNote(noteData)
.then((res) => {
this.isSubmitting = false;
if (res.errors) {
if (res.errors.commands_only) {
this.discard();
} else {
Flash(
'Something went wrong while adding your comment. Please try again.',
'alert',
$(this.$refs.commentForm),
);
}
} else {
this.discard();
}
if (withIssueAction) {
this.toggleIssueState();
}
})
.catch(() => {
this.isSubmitting = false;
this.discard(false);
const msg = 'Your comment could not be submitted! Please check your network connection and try again.';
Flash(msg, 'alert', $(this.$el));
this.note = noteData.data.note.note; // Restore textarea content.
this.removePlaceholderNotes();
});
} else {
this.toggleIssueState();
}
},
toggleIssueState() {
this.issueState = this.isIssueOpen ? constants.CLOSED : constants.REOPENED;
// This is out of scope for the Notes Vue component.
// It was the shortest path to update the issue state and relevant places.
const btnClass = this.isIssueOpen ? 'btn-reopen' : 'btn-close';
$(`.js-btn-issue-action.${btnClass}:visible`).trigger('click');
},
discard(shouldClear = true) {
// `blur` is needed to clear slash commands autocomplete cache if event fired.
// `focus` is needed to remain cursor in the textarea.
this.$refs.textarea.blur();
this.$refs.textarea.focus();
if (shouldClear) {
this.note = '';
}
// reset autostave
this.autosave.reset();
},
setNoteType(type) {
this.noteType = type;
},
editCurrentUserLastNote() {
if (this.note === '') {
const lastNote = this.getCurrentUserLastNote;
if (lastNote) {
eventHub.$emit('enterEditMode', {
noteId: lastNote.id,
});
}
}
},
initAutoSave() {
if (this.isLoggedIn) {
this.autosave = new Autosave($(this.$refs.textarea), ['Note', 'Issue', this.getIssueData.id], 'issue');
}
},
initTaskList() {
return new TaskList({
dataType: 'note',
fieldName: 'note',
selector: '.notes',
});
},
},
mounted() {
// jQuery is needed here because it is a custom event being dispatched with jQuery.
$(document).on('issuable:change', (e, isClosed) => {
this.issueState = isClosed ? constants.CLOSED : constants.REOPENED;
});
this.initAutoSave();
this.initTaskList();
},
};
</script>
<template>
<div>
<issue-note-signed-out-widget v-if="!isLoggedIn" />
<ul
v-else
class="notes notes-form timeline">
<li class="timeline-entry">
<div class="timeline-entry-inner">
<div class="flash-container error-alert timeline-content"></div>
<div class="timeline-icon hidden-xs hidden-sm">
<user-avatar-link
v-if="author"
:link-href="author.path"
:img-src="author.avatar_url"
:img-alt="author.name"
:img-size="40"
/>
</div>
<div class="timeline-content timeline-content-form">
<form
ref="commentForm"
class="new-note js-quick-submit common-note-form gfm-form js-main-target-form">
<confidentialIssue v-if="isConfidentialIssue" />
<div class="error-alert"></div>
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
:add-spacing-classes="false"
:is-confidential-issue="isConfidentialIssue">
<textarea
id="note-body"
name="note[note]"
class="note-textarea js-vue-comment-form js-gfm-input js-autosize markdown-area js-vue-textarea"
data-supports-quick-actions="true"
aria-label="Description"
v-model="note"
ref="textarea"
slot="textarea"
placeholder="Write a comment or drag your files here..."
@keydown.up="editCurrentUserLastNote()"
@keydown.meta.enter="handleSave()">
</textarea>
</markdown-field>
<div class="note-form-actions">
<div class="pull-left btn-group append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown">
<button
@click.prevent="handleSave()"
:disabled="isSubmitButtonDisabled"
class="btn btn-create comment-btn js-comment-button js-comment-submit-button"
type="submit">
{{commentButtonTitle}}
</button>
<button
:disabled="isSubmitButtonDisabled"
name="button"
type="button"
class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle"
data-toggle="dropdown"
aria-label="Open comment type dropdown">
<i
aria-hidden="true"
class="fa fa-caret-down toggle-icon">
</i>
</button>
<ul class="note-type-dropdown dropdown-open-top dropdown-menu">
<li :class="{ 'droplab-item-selected': noteType === 'comment' }">
<button
type="button"
class="btn btn-transparent"
@click.prevent="setNoteType('comment')">
<i
aria-hidden="true"
class="fa fa-check icon">
</i>
<div class="description">
<strong>Comment</strong>
<p>
Add a general comment to this issue.
</p>
</div>
</button>
</li>
<li class="divider droplab-item-ignore"></li>
<li :class="{ 'droplab-item-selected': noteType === 'discussion' }">
<button
type="button"
class="btn btn-transparent"
@click.prevent="setNoteType('discussion')">
<i
aria-hidden="true"
class="fa fa-check icon">
</i>
<div class="description">
<strong>Start discussion</strong>
<p>
Discuss a specific suggestion or question.
</p>
</div>
</button>
</li>
</ul>
</div>
<button
type="button"
@click="handleSave(true)"
v-if="canUpdateIssue"
:class="actionButtonClassNames"
class="btn btn-comment btn-comment-and-close">
{{issueActionButtonTitle}}
</button>
<button
type="button"
v-if="note.length"
@click="discard"
class="btn btn-cancel js-note-discard">
Discard draft
</button>
</div>
</form>
</div>
</div>
</li>
</ul>
</div>
</template>

View File

@ -0,0 +1,232 @@
<script>
/* global Flash */
import { mapActions, mapGetters } from 'vuex';
import { SYSTEM_NOTE } from '../constants';
import issueNote from './issue_note.vue';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import issueNoteHeader from './issue_note_header.vue';
import issueNoteActions from './issue_note_actions.vue';
import issueNoteSignedOutWidget from './issue_note_signed_out_widget.vue';
import issueNoteEditedText from './issue_note_edited_text.vue';
import issueNoteForm from './issue_note_form.vue';
import placeholderNote from './issue_placeholder_note.vue';
import placeholderSystemNote from './issue_placeholder_system_note.vue';
import autosave from '../mixins/autosave';
export default {
props: {
note: {
type: Object,
required: true,
},
},
data() {
return {
isReplying: false,
};
},
components: {
issueNote,
userAvatarLink,
issueNoteHeader,
issueNoteActions,
issueNoteSignedOutWidget,
issueNoteEditedText,
issueNoteForm,
placeholderNote,
placeholderSystemNote,
},
mixins: [
autosave,
],
computed: {
...mapGetters([
'getIssueData',
]),
discussion() {
return this.note.notes[0];
},
author() {
return this.discussion.author;
},
canReply() {
return this.getIssueData.current_user.can_create_note;
},
newNotePath() {
return this.getIssueData.create_note_path;
},
lastUpdatedBy() {
const { notes } = this.note;
if (notes.length > 1) {
return notes[notes.length - 1].author;
}
return null;
},
lastUpdatedAt() {
const { notes } = this.note;
if (notes.length > 1) {
return notes[notes.length - 1].created_at;
}
return null;
},
},
methods: {
...mapActions([
'saveNote',
'toggleDiscussion',
'removePlaceholderNotes',
]),
componentName(note) {
if (note.isPlaceholderNote) {
if (note.placeholderType === SYSTEM_NOTE) {
return placeholderSystemNote;
}
return placeholderNote;
}
return issueNote;
},
componentData(note) {
return note.isPlaceholderNote ? note.notes[0] : note;
},
toggleDiscussionHandler() {
this.toggleDiscussion({ discussionId: this.note.id });
},
showReplyForm() {
this.isReplying = true;
},
cancelReplyForm(shouldConfirm) {
if (shouldConfirm && this.$refs.noteForm.isDirty) {
// eslint-disable-next-line no-alert
if (!confirm('Are you sure you want to cancel creating this comment?')) {
return;
}
}
this.resetAutoSave();
this.isReplying = false;
},
saveReply(noteText, form, callback) {
const replyData = {
endpoint: this.newNotePath,
flashContainer: this.$el,
data: {
in_reply_to_discussion_id: this.note.reply_id,
target_type: 'issue',
target_id: this.discussion.noteable_id,
note: { note: noteText },
},
};
this.isReplying = false;
this.saveNote(replyData)
.then(() => {
this.resetAutoSave();
callback();
})
.catch((err) => {
this.removePlaceholderNotes();
this.isReplying = true;
this.$nextTick(() => {
const msg = 'Your comment could not be submitted! Please check your network connection and try again.';
Flash(msg, 'alert', $(this.$el));
this.$refs.noteForm.note = noteText;
callback(err);
});
});
},
},
mounted() {
if (this.isReplying) {
this.initAutoSave();
}
},
updated() {
if (this.isReplying) {
if (!this.autosave) {
this.initAutoSave();
} else {
this.setAutoSave();
}
}
},
};
</script>
<template>
<li class="note note-discussion timeline-entry">
<div class="timeline-entry-inner">
<div class="timeline-icon">
<user-avatar-link
:link-href="author.path"
:img-src="author.avatar_url"
:img-alt="author.name"
:img-size="40"
/>
</div>
<div class="timeline-content">
<div class="discussion">
<div class="discussion-header">
<issue-note-header
:author="author"
:created-at="discussion.created_at"
:note-id="discussion.id"
:include-toggle="true"
@toggleHandler="toggleDiscussionHandler"
action-text="started a discussion"
class="discussion"
/>
<issue-note-edited-text
v-if="lastUpdatedAt"
:edited-at="lastUpdatedAt"
:edited-by="lastUpdatedBy"
action-text="Last updated"
class-name="discussion-headline-light js-discussion-headline"
/>
</div>
</div>
<div
v-if="note.expanded"
class="discussion-body">
<div class="panel panel-default">
<div class="discussion-notes">
<ul class="notes">
<component
v-for="note in note.notes"
:is="componentName(note)"
:note="componentData(note)"
:key="note.id"
/>
</ul>
<div
:class="{ 'is-replying': isReplying }"
class="discussion-reply-holder">
<button
v-if="canReply && !isReplying"
@click="showReplyForm"
type="button"
class="js-vue-discussion-reply btn btn-text-field"
title="Add a reply">Reply...</button>
<issue-note-form
v-if="isReplying"
save-button-title="Comment"
:discussion="note"
:is-editing="false"
@handleFormUpdate="saveReply"
@cancelFormEdition="cancelReplyForm"
ref="noteForm"
/>
<issue-note-signed-out-widget v-if="!canReply" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</li>
</template>

View File

@ -0,0 +1,186 @@
<script>
/* global Flash */
import { mapGetters, mapActions } from 'vuex';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import issueNoteHeader from './issue_note_header.vue';
import issueNoteActions from './issue_note_actions.vue';
import issueNoteBody from './issue_note_body.vue';
import eventHub from '../event_hub';
export default {
props: {
note: {
type: Object,
required: true,
},
},
data() {
return {
isEditing: false,
isDeleting: false,
isRequesting: false,
};
},
components: {
userAvatarLink,
issueNoteHeader,
issueNoteActions,
issueNoteBody,
},
computed: {
...mapGetters([
'targetNoteHash',
'getUserData',
]),
author() {
return this.note.author;
},
classNameBindings() {
return {
'is-editing': this.isEditing && !this.isRequesting,
'is-requesting being-posted': this.isRequesting,
'disabled-content': this.isDeleting,
target: this.targetNoteHash === this.noteAnchorId,
};
},
canReportAsAbuse() {
return this.note.report_abuse_path && this.author.id !== this.getUserData.id;
},
noteAnchorId() {
return `note_${this.note.id}`;
},
},
methods: {
...mapActions([
'deleteNote',
'updateNote',
'scrollToNoteIfNeeded',
]),
editHandler() {
this.isEditing = true;
},
deleteHandler() {
// eslint-disable-next-line no-alert
if (confirm('Are you sure you want to delete this list?')) {
this.isDeleting = true;
this.deleteNote(this.note)
.then(() => {
this.isDeleting = false;
})
.catch(() => {
Flash('Something went wrong while deleting your note. Please try again.');
this.isDeleting = false;
});
}
},
formUpdateHandler(noteText, parentElement, callback) {
const data = {
endpoint: this.note.path,
note: {
target_type: 'issue',
target_id: this.note.noteable_id,
note: { note: noteText },
},
};
this.isRequesting = true;
this.oldContent = this.note.note_html;
this.note.note_html = noteText;
this.updateNote(data)
.then(() => {
this.isEditing = false;
this.isRequesting = false;
$(this.$refs.noteBody.$el).renderGFM();
this.$refs.noteBody.resetAutoSave();
callback();
})
.catch(() => {
this.isRequesting = false;
this.isEditing = true;
this.$nextTick(() => {
const msg = 'Something went wrong while editing your comment. Please try again.';
Flash(msg, 'alert', $(this.$el));
this.recoverNoteContent(noteText);
callback();
});
});
},
formCancelHandler(shouldConfirm, isDirty) {
if (shouldConfirm && isDirty) {
// eslint-disable-next-line no-alert
if (!confirm('Are you sure you want to cancel editing this comment?')) return;
}
this.$refs.noteBody.resetAutoSave();
if (this.oldContent) {
this.note.note_html = this.oldContent;
this.oldContent = null;
}
this.isEditing = false;
},
recoverNoteContent(noteText) {
// we need to do this to prevent noteForm inconsistent content warning
// this is something we intentionally do so we need to recover the content
this.note.note = noteText;
this.$refs.noteBody.$refs.noteForm.note = noteText; // TODO: This could be better
},
},
created() {
eventHub.$on('enterEditMode', ({ noteId }) => {
if (noteId === this.note.id) {
this.isEditing = true;
this.scrollToNoteIfNeeded($(this.$el));
}
});
},
};
</script>
<template>
<li
class="note timeline-entry"
:id="noteAnchorId"
:class="classNameBindings"
:data-award-url="note.toggle_award_path">
<div class="timeline-entry-inner">
<div class="timeline-icon">
<user-avatar-link
:link-href="author.path"
:img-src="author.avatar_url"
:img-alt="author.name"
:img-size="40"
/>
</div>
<div class="timeline-content">
<div class="note-header">
<issue-note-header
:author="author"
:created-at="note.created_at"
:note-id="note.id"
action-text="commented"
/>
<issue-note-actions
:author-id="author.id"
:note-id="note.id"
:access-level="note.human_access"
:can-edit="note.current_user.can_edit"
:can-delete="note.current_user.can_edit"
:can-report-as-abuse="canReportAsAbuse"
:report-abuse-path="note.report_abuse_path"
@handleEdit="editHandler"
@handleDelete="deleteHandler"
/>
</div>
<issue-note-body
:note="note"
:can-edit="note.current_user.can_edit"
:is-editing="isEditing"
@handleFormUpdate="formUpdateHandler"
@cancelFormEdition="formCancelHandler"
ref="noteBody"
/>
</div>
</div>
</li>
</template>

View File

@ -0,0 +1,167 @@
<script>
import { mapGetters } from 'vuex';
import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg';
import emojiSmile from 'icons/_emoji_smile.svg';
import emojiSmiley from 'icons/_emoji_smiley.svg';
import editSvg from 'icons/_icon_pencil.svg';
import ellipsisSvg from 'icons/_ellipsis_v.svg';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
name: 'issueNoteActions',
props: {
authorId: {
type: Number,
required: true,
},
noteId: {
type: Number,
required: true,
},
accessLevel: {
type: String,
required: false,
default: '',
},
reportAbusePath: {
type: String,
required: true,
},
canEdit: {
type: Boolean,
required: true,
},
canDelete: {
type: Boolean,
required: true,
},
canReportAsAbuse: {
type: Boolean,
required: true,
},
},
directives: {
tooltip,
},
components: {
loadingIcon,
},
computed: {
...mapGetters([
'getUserDataByProp',
]),
shouldShowActionsDropdown() {
return this.currentUserId && (this.canEdit || this.canReportAsAbuse);
},
canAddAwardEmoji() {
return this.currentUserId;
},
isAuthoredByCurrentUser() {
return this.authorId === this.currentUserId;
},
currentUserId() {
return this.getUserDataByProp('id');
},
},
methods: {
onEdit() {
this.$emit('handleEdit');
},
onDelete() {
this.$emit('handleDelete');
},
},
created() {
this.emojiSmiling = emojiSmiling;
this.emojiSmile = emojiSmile;
this.emojiSmiley = emojiSmiley;
this.editSvg = editSvg;
this.ellipsisSvg = ellipsisSvg;
},
};
</script>
<template>
<div class="note-actions">
<span
v-if="accessLevel"
class="note-role">{{accessLevel}}</span>
<div
v-if="canAddAwardEmoji"
class="note-actions-item">
<a
v-tooltip
:class="{ 'js-user-authored': isAuthoredByCurrentUser }"
class="note-action-button note-emoji-button js-add-award js-note-emoji"
data-position="right"
data-placement="bottom"
data-container="body"
href="#"
title="Add reaction">
<loading-icon :inline="true" />
<span
v-html="emojiSmiling"
class="link-highlight award-control-icon-neutral">
</span>
<span
v-html="emojiSmiley"
class="link-highlight award-control-icon-positive">
</span>
<span
v-html="emojiSmile"
class="link-highlight award-control-icon-super-positive">
</span>
</a>
</div>
<div
v-if="canEdit"
class="note-actions-item">
<button
@click="onEdit"
v-tooltip
type="button"
title="Edit comment"
class="note-action-button js-note-edit btn btn-transparent"
data-container="body"
data-placement="bottom">
<span
v-html="editSvg"
class="link-highlight"></span>
</button>
</div>
<div
v-if="shouldShowActionsDropdown"
class="dropdown more-actions note-actions-item">
<button
v-tooltip
type="button"
title="More actions"
class="note-action-button more-actions-toggle btn btn-transparent"
data-toggle="dropdown"
data-container="body"
data-placement="bottom">
<span
class="icon"
v-html="ellipsisSvg"></span>
</button>
<ul class="dropdown-menu more-actions-dropdown dropdown-open-left">
<li v-if="canReportAsAbuse">
<a :href="reportAbusePath">
Report as abuse
</a>
</li>
<li v-if="canEdit">
<button
@click.prevent="onDelete"
class="btn btn-transparent js-note-delete js-note-delete"
type="button">
<span class="text-danger">
Delete comment
</span>
</button>
</li>
</ul>
</div>
</div>
</template>

View File

@ -0,0 +1,37 @@
<script>
export default {
name: 'issueNoteAttachment',
props: {
attachment: {
type: Object,
required: true,
},
},
};
</script>
<template>
<div class="note-attachment">
<a
v-if="attachment.image"
:href="attachment.url"
target="_blank"
rel="noopener noreferrer">
<img
:src="attachment.url"
class="note-image-attach" />
</a>
<div class="attachment">
<a
v-if="attachment.url"
:href="attachment.url"
target="_blank"
rel="noopener noreferrer">
<i
class="fa fa-paperclip"
aria-hidden="true"></i>
{{attachment.filename}}
</a>
</div>
</div>
</template>

View File

@ -0,0 +1,228 @@
<script>
/* global Flash */
import { mapActions, mapGetters } from 'vuex';
import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg';
import emojiSmile from 'icons/_emoji_smile.svg';
import emojiSmiley from 'icons/_emoji_smiley.svg';
import { glEmojiTag } from '../../emoji';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
props: {
awards: {
type: Array,
required: true,
},
toggleAwardPath: {
type: String,
required: true,
},
noteAuthorId: {
type: Number,
required: true,
},
noteId: {
type: Number,
required: true,
},
},
directives: {
tooltip,
},
computed: {
...mapGetters([
'getUserData',
]),
// `this.awards` is an array with emojis but they are not grouped by emoji name. See below.
// [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ]
// This method will group emojis by their name as an Object. See below.
// {
// foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ],
// bar: [ { name: bar, user: user1 } ]
// }
// We need to do this otherwise we will render the same emoji over and over again.
groupedAwards() {
const awards = this.awards.reduce((acc, award) => {
if (Object.prototype.hasOwnProperty.call(acc, award.name)) {
acc[award.name].push(award);
} else {
Object.assign(acc, { [award.name]: [award] });
}
return acc;
}, {});
const orderedAwards = {};
const { thumbsdown, thumbsup } = awards;
// Always show thumbsup and thumbsdown first
if (thumbsup) {
orderedAwards.thumbsup = thumbsup;
delete awards.thumbsup;
}
if (thumbsdown) {
orderedAwards.thumbsdown = thumbsdown;
delete awards.thumbsdown;
}
return Object.assign({}, orderedAwards, awards);
},
isAuthoredByMe() {
return this.noteAuthorId === this.getUserData.id;
},
isLoggedIn() {
return this.getUserData.id;
},
},
methods: {
...mapActions([
'toggleAwardRequest',
]),
getAwardHTML(name) {
return glEmojiTag(name);
},
getAwardClassBindings(awardList, awardName) {
return {
active: this.hasReactionByCurrentUser(awardList),
disabled: !this.canInteractWithEmoji(awardList, awardName),
};
},
canInteractWithEmoji(awardList, awardName) {
let isAllowed = true;
const restrictedEmojis = ['thumbsup', 'thumbsdown'];
// Users can not add :+1: and :-1: to their own notes
if (this.getUserData.id === this.noteAuthorId && restrictedEmojis.indexOf(awardName) > -1) {
isAllowed = false;
}
return this.getUserData.id && isAllowed;
},
hasReactionByCurrentUser(awardList) {
return awardList.filter(award => award.user.id === this.getUserData.id).length;
},
awardTitle(awardsList) {
const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList);
const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10;
let awardList = awardsList;
// Filter myself from list if I am awarded.
if (hasReactionByCurrentUser) {
awardList = awardList.filter(award => award.user.id !== this.getUserData.id);
}
// Get only 9-10 usernames to show in tooltip text.
const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name);
// Get the remaining list to use in `and x more` text.
const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length);
// Add myself to the begining of the list so title will start with You.
if (hasReactionByCurrentUser) {
namesToShow.unshift('You');
}
let title = '';
// We have 10+ awarded user, join them with comma and add `and x more`.
if (remainingAwardList.length) {
title = `${namesToShow.join(', ')}, and ${remainingAwardList.length} more.`;
} else if (namesToShow.length > 1) {
// Join all names with comma but not the last one, it will be added with and text.
title = namesToShow.slice(0, namesToShow.length - 1).join(', ');
// If we have more than 2 users we need an extra comma before and text.
title += namesToShow.length > 2 ? ',' : '';
title += ` and ${namesToShow.slice(-1)}`; // Append and text
} else { // We have only 2 users so join them with and.
title = namesToShow.join(' and ');
}
return title;
},
handleAward(awardName) {
if (!this.isLoggedIn) {
return;
}
let parsedName;
// 100 and 1234 emoji are a number. Callback for v-for click sends it as a string
switch (awardName) {
case '100':
parsedName = 100;
break;
case '1234':
parsedName = 1234;
break;
default:
parsedName = awardName;
break;
}
const data = {
endpoint: this.toggleAwardPath,
noteId: this.noteId,
awardName: parsedName,
};
this.toggleAwardRequest(data)
.catch(() => Flash('Something went wrong on our end.'));
},
},
created() {
this.emojiSmiling = emojiSmiling;
this.emojiSmile = emojiSmile;
this.emojiSmiley = emojiSmiley;
},
};
</script>
<template>
<div class="note-awards">
<div class="awards js-awards-block">
<button
v-tooltip
v-for="(awardList, awardName, index) in groupedAwards"
:key="index"
:class="getAwardClassBindings(awardList, awardName)"
:title="awardTitle(awardList)"
@click="handleAward(awardName)"
class="btn award-control"
data-placement="bottom"
type="button">
<span v-html="getAwardHTML(awardName)"></span>
<span class="award-control-text js-counter">
{{awardList.length}}
</span>
</button>
<div
v-if="isLoggedIn"
class="award-menu-holder">
<button
v-tooltip
:class="{ 'js-user-authored': isAuthoredByMe }"
class="award-control btn js-add-award"
title="Add reaction"
aria-label="Add reaction"
data-placement="bottom"
type="button">
<span
v-html="emojiSmiling"
class="award-control-icon award-control-icon-neutral">
</span>
<span
v-html="emojiSmiley"
class="award-control-icon award-control-icon-positive">
</span>
<span
v-html="emojiSmile"
class="award-control-icon award-control-icon-super-positive">
</span>
<i
aria-hidden="true"
class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"></i>
</button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,122 @@
<script>
import issueNoteEditedText from './issue_note_edited_text.vue';
import issueNoteAwardsList from './issue_note_awards_list.vue';
import issueNoteAttachment from './issue_note_attachment.vue';
import issueNoteForm from './issue_note_form.vue';
import TaskList from '../../task_list';
import autosave from '../mixins/autosave';
export default {
props: {
note: {
type: Object,
required: true,
},
canEdit: {
type: Boolean,
required: true,
},
isEditing: {
type: Boolean,
required: false,
default: false,
},
},
mixins: [
autosave,
],
components: {
issueNoteEditedText,
issueNoteAwardsList,
issueNoteAttachment,
issueNoteForm,
},
computed: {
noteBody() {
return this.note.note;
},
},
methods: {
renderGFM() {
$(this.$refs['note-body']).renderGFM();
},
initTaskList() {
if (this.canEdit) {
this.taskList = new TaskList({
dataType: 'note',
fieldName: 'note',
selector: '.notes',
});
}
},
handleFormUpdate(note, parentElement, callback) {
this.$emit('handleFormUpdate', note, parentElement, callback);
},
formCancelHandler(shouldConfirm, isDirty) {
this.$emit('cancelFormEdition', shouldConfirm, isDirty);
},
},
mounted() {
this.renderGFM();
this.initTaskList();
if (this.isEditing) {
this.initAutoSave();
}
},
updated() {
this.initTaskList();
this.renderGFM();
if (this.isEditing) {
if (!this.autosave) {
this.initAutoSave();
} else {
this.setAutoSave();
}
}
},
};
</script>
<template>
<div
:class="{ 'js-task-list-container': canEdit }"
ref="note-body"
class="note-body">
<div
v-html="note.note_html"
class="note-text md"></div>
<issue-note-form
v-if="isEditing"
ref="noteForm"
@handleFormUpdate="handleFormUpdate"
@cancelFormEdition="formCancelHandler"
:is-editing="isEditing"
:note-body="noteBody"
:note-id="note.id"
/>
<textarea
v-if="canEdit"
v-model="note.note"
:data-update-url="note.path"
class="hidden js-task-list-field"></textarea>
<issue-note-edited-text
v-if="note.last_edited_at"
:edited-at="note.last_edited_at"
:edited-by="note.last_edited_by"
action-text="Edited"
/>
<issue-note-awards-list
v-if="note.award_emoji.length"
:note-id="note.id"
:note-author-id="note.author.id"
:awards="note.award_emoji"
:toggle-award-path="note.toggle_award_path"
/>
<issue-note-attachment
v-if="note.attachment"
:attachment="note.attachment"
/>
</div>
</template>

View File

@ -0,0 +1,47 @@
<script>
import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
export default {
name: 'editedNoteText',
props: {
actionText: {
type: String,
required: true,
},
editedAt: {
type: String,
required: true,
},
editedBy: {
type: Object,
required: false,
},
className: {
type: String,
required: false,
default: 'edited-text',
},
},
components: {
timeAgoTooltip,
},
};
</script>
<template>
<div :class="className">
{{actionText}}
<time-ago-tooltip
:time="editedAt"
tooltip-placement="bottom"
/>
<template v-if="editedBy">
by
<a
:href="editedBy.path"
class="js-vue-author author_link">
{{editedBy.name}}
</a>
</template>
</div>
</template>

View File

@ -0,0 +1,166 @@
<script>
import { mapGetters } from 'vuex';
import eventHub from '../event_hub';
import confidentialIssue from '../../vue_shared/components/issue/confidential_issue_warning.vue';
import markdownField from '../../vue_shared/components/markdown/field.vue';
export default {
name: 'issueNoteForm',
props: {
noteBody: {
type: String,
required: false,
default: '',
},
noteId: {
type: Number,
required: false,
},
saveButtonTitle: {
type: String,
required: false,
default: 'Save comment',
},
discussion: {
type: Object,
required: false,
default: () => ({}),
},
isEditing: {
type: Boolean,
required: true,
},
},
data() {
return {
note: this.noteBody,
conflictWhileEditing: false,
isSubmitting: false,
};
},
components: {
confidentialIssue,
markdownField,
},
computed: {
...mapGetters([
'getDiscussionLastNote',
'getIssueDataByProp',
'getNotesDataByProp',
'getUserDataByProp',
]),
noteHash() {
return `#note_${this.noteId}`;
},
markdownPreviewPath() {
return this.getIssueDataByProp('preview_note_path');
},
markdownDocsPath() {
return this.getNotesDataByProp('markdownDocsPath');
},
quickActionsDocsPath() {
return !this.isEditing ? this.getNotesDataByProp('quickActionsDocsPath') : undefined;
},
currentUserId() {
return this.getUserDataByProp('id');
},
isDisabled() {
return !this.note.length || this.isSubmitting;
},
isConfidentialIssue() {
return this.getIssueDataByProp('confidential');
},
},
methods: {
handleUpdate() {
this.isSubmitting = true;
this.$emit('handleFormUpdate', this.note, this.$refs.editNoteForm, () => {
this.isSubmitting = false;
});
},
editMyLastNote() {
if (this.note === '') {
const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion);
if (lastNoteInDiscussion) {
eventHub.$emit('enterEditMode', {
noteId: lastNoteInDiscussion.id,
});
}
}
},
cancelHandler(shouldConfirm = false) {
// Sends information about confirm message and if the textarea has changed
this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.note);
},
},
mounted() {
this.$refs.textarea.focus();
},
watch: {
noteBody() {
if (this.note === this.noteBody) {
this.note = this.noteBody;
} else {
this.conflictWhileEditing = true;
}
},
},
};
</script>
<template>
<div ref="editNoteForm" class="note-edit-form current-note-edit-form">
<div
v-if="conflictWhileEditing"
class="js-conflict-edit-warning alert alert-danger">
This comment has changed since you started editing, please review the
<a
:href="noteHash"
target="_blank"
rel="noopener noreferrer">updated comment</a>
to ensure information is not lost.
</div>
<div class="flash-container timeline-content"></div>
<form
class="edit-note common-note-form js-quick-submit gfm-form">
<confidentialIssue v-if="isConfidentialIssue" />
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
:add-spacing-classes="false">
<textarea
id="note_note"
name="note[note]"
class="note-textarea js-gfm-input js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
:data-supports-quick-actions="!isEditing"
aria-label="Description"
v-model="note"
ref="textarea"
slot="textarea"
placeholder="Write a comment or drag your files here..."
@keydown.meta.enter="handleUpdate()"
@keydown.up="editMyLastNote()"
@keydown.esc="cancelHandler(true)">
</textarea>
</markdown-field>
<div class="note-form-actions clearfix">
<button
type="button"
@click="handleUpdate()"
:disabled="isDisabled"
class="js-vue-issue-save btn btn-save">
{{saveButtonTitle}}
</button>
<button
@click="cancelHandler()"
class="btn btn-cancel note-edit-cancel"
type="button">
Cancel
</button>
</div>
</form>
</div>
</template>

View File

@ -0,0 +1,118 @@
<script>
import { mapActions } from 'vuex';
import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
export default {
props: {
author: {
type: Object,
required: true,
},
createdAt: {
type: String,
required: true,
},
actionText: {
type: String,
required: false,
default: '',
},
actionTextHtml: {
type: String,
required: false,
default: '',
},
noteId: {
type: Number,
required: true,
},
includeToggle: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
isExpanded: true,
};
},
components: {
timeAgoTooltip,
},
computed: {
toggleChevronClass() {
return this.isExpanded ? 'fa-chevron-up' : 'fa-chevron-down';
},
noteTimestampLink() {
return `#note_${this.noteId}`;
},
},
methods: {
...mapActions([
'setTargetNoteHash',
]),
handleToggle() {
this.isExpanded = !this.isExpanded;
this.$emit('toggleHandler');
},
updateTargetNoteHash() {
this.setTargetNoteHash(this.noteTimestampLink);
},
},
};
</script>
<template>
<div class="note-header-info">
<a :href="author.path">
<span class="note-header-author-name">
{{author.name}}
</span>
<span class="note-headline-light">
@{{author.username}}
</span>
</a>
<span class="note-headline-light">
<span class="note-headline-meta">
<template v-if="actionText">
{{actionText}}
</template>
<span
v-if="actionTextHtml"
v-html="actionTextHtml"
class="system-note-message">
</span>
<a
:href="noteTimestampLink"
@click="updateTargetNoteHash"
class="note-timestamp">
<time-ago-tooltip
:time="createdAt"
tooltip-placement="bottom"
/>
</a>
<i
class="fa fa-spinner fa-spin editing-spinner"
aria-label="Comment is being updated"
aria-hidden="true">
</i>
</span>
</span>
<div
v-if="includeToggle"
class="discussion-actions">
<button
@click="handleToggle"
class="note-action-button discussion-toggle-button js-vue-toggle-button"
type="button">
<i
:class="toggleChevronClass"
class="fa"
aria-hidden="true">
</i>
Toggle discussion
</button>
</div>
</div>
</template>

View File

@ -0,0 +1,37 @@
import iconArrowCircle from 'icons/_icon_arrow_circle_o_right.svg';
import iconCheck from 'icons/_icon_check_square_o.svg';
import iconClock from 'icons/_icon_clock_o.svg';
import iconCodeFork from 'icons/_icon_code_fork.svg';
import iconComment from 'icons/_icon_comment_o.svg';
import iconCommit from 'icons/_icon_commit.svg';
import iconEdit from 'icons/_icon_edit.svg';
import iconEye from 'icons/_icon_eye.svg';
import iconEyeSlash from 'icons/_icon_eye_slash.svg';
import iconMerge from 'icons/_icon_merge.svg';
import iconMerged from 'icons/_icon_merged.svg';
import iconRandom from 'icons/_icon_random.svg';
import iconClosed from 'icons/_icon_status_closed.svg';
import iconStatusOpen from 'icons/_icon_status_open.svg';
import iconStopwatch from 'icons/_icon_stopwatch.svg';
import iconTags from 'icons/_icon_tags.svg';
import iconUser from 'icons/_icon_user.svg';
export default {
icon_arrow_circle_o_right: iconArrowCircle,
icon_check_square_o: iconCheck,
icon_clock_o: iconClock,
icon_code_fork: iconCodeFork,
icon_comment_o: iconComment,
icon_commit: iconCommit,
icon_edit: iconEdit,
icon_eye: iconEye,
icon_eye_slash: iconEyeSlash,
icon_merge: iconMerge,
icon_merged: iconMerged,
icon_random: iconRandom,
icon_status_closed: iconClosed,
icon_status_open: iconStatusOpen,
icon_stopwatch: iconStopwatch,
icon_tags: iconTags,
icon_user: iconUser,
};

View File

@ -0,0 +1,28 @@
<script>
import { mapGetters } from 'vuex';
export default {
name: 'singInLinksNotes',
computed: {
...mapGetters([
'getNotesDataByProp',
]),
registerLink() {
return this.getNotesDataByProp('registerPath');
},
signInLink() {
return this.getNotesDataByProp('newSessionPath');
},
},
};
</script>
<template>
<div class="disabled-comment text-center">
Please
<a :href="registerLink">register</a>
or
<a :href="signInLink">sign in</a>
to reply
</div>
</template>

View File

@ -0,0 +1,151 @@
<script>
/* global Flash */
import { mapGetters, mapActions } from 'vuex';
import store from '../stores/';
import * as constants from '../constants';
import issueNote from './issue_note.vue';
import issueDiscussion from './issue_discussion.vue';
import issueSystemNote from './issue_system_note.vue';
import issueCommentForm from './issue_comment_form.vue';
import placeholderNote from './issue_placeholder_note.vue';
import placeholderSystemNote from './issue_placeholder_system_note.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
name: 'issueNotesApp',
props: {
issueData: {
type: Object,
required: true,
},
notesData: {
type: Object,
required: true,
},
userData: {
type: Object,
required: false,
default: {},
},
},
store,
data() {
return {
isLoading: true,
};
},
components: {
issueNote,
issueDiscussion,
issueSystemNote,
issueCommentForm,
loadingIcon,
placeholderNote,
placeholderSystemNote,
},
computed: {
...mapGetters([
'notes',
'getNotesDataByProp',
]),
},
methods: {
...mapActions({
actionFetchNotes: 'fetchNotes',
poll: 'poll',
actionToggleAward: 'toggleAward',
scrollToNoteIfNeeded: 'scrollToNoteIfNeeded',
setNotesData: 'setNotesData',
setIssueData: 'setIssueData',
setUserData: 'setUserData',
setLastFetchedAt: 'setLastFetchedAt',
setTargetNoteHash: 'setTargetNoteHash',
}),
getComponentName(note) {
if (note.isPlaceholderNote) {
if (note.placeholderType === constants.SYSTEM_NOTE) {
return placeholderSystemNote;
}
return placeholderNote;
} else if (note.individual_note) {
return note.notes[0].system ? issueSystemNote : issueNote;
}
return issueDiscussion;
},
getComponentData(note) {
return note.individual_note ? note.notes[0] : note;
},
fetchNotes() {
return this.actionFetchNotes(this.getNotesDataByProp('discussionsPath'))
.then(() => this.initPolling())
.then(() => {
this.isLoading = false;
})
.then(() => this.$nextTick())
.then(() => this.checkLocationHash())
.catch(() => {
this.isLoading = false;
Flash('Something went wrong while fetching issue comments. Please try again.');
});
},
initPolling() {
this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt'));
this.poll();
},
checkLocationHash() {
const hash = gl.utils.getLocationHash();
const element = document.getElementById(hash);
if (hash && element) {
this.setTargetNoteHash(hash);
this.scrollToNoteIfNeeded($(element));
}
},
},
created() {
this.setNotesData(this.notesData);
this.setIssueData(this.issueData);
this.setUserData(this.userData);
},
mounted() {
this.fetchNotes();
const parentElement = this.$el.parentElement;
if (parentElement &&
parentElement.classList.contains('js-vue-notes-event')) {
parentElement.addEventListener('toggleAward', (event) => {
const { awardName, noteId } = event.detail;
this.actionToggleAward({ awardName, noteId });
});
}
},
};
</script>
<template>
<div id="notes">
<div
v-if="isLoading"
class="js-loading loading">
<loading-icon />
</div>
<ul
v-if="!isLoading"
id="notes-list"
class="notes main-notes-list timeline">
<component
v-for="note in notes"
:is="getComponentName(note)"
:note="getComponentData(note)"
:key="note.id"
/>
</ul>
<issue-comment-form />
</div>
</template>

View File

@ -0,0 +1,53 @@
<script>
import { mapGetters } from 'vuex';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
export default {
name: 'issuePlaceholderNote',
props: {
note: {
type: Object,
required: true,
},
},
components: {
userAvatarLink,
},
computed: {
...mapGetters([
'getUserData',
]),
},
};
</script>
<template>
<li class="note being-posted fade-in-half timeline-entry">
<div class="timeline-entry-inner">
<div class="timeline-icon">
<user-avatar-link
:link-href="getUserData.path"
:img-src="getUserData.avatar_url"
:img-size="40"
/>
</div>
<div
:class="{ discussion: !note.individual_note }"
class="timeline-content">
<div class="note-header">
<div class="note-header-info">
<a :href="getUserData.path">
<span class="hidden-xs">{{getUserData.name}}</span>
<span class="note-headline-light">@{{getUserData.username}}</span>
</a>
</div>
</div>
<div class="note-body">
<div class="note-text">
<p>{{note.body}}</p>
</div>
</div>
</div>
</div>
</li>
</template>

View File

@ -0,0 +1,21 @@
<script>
export default {
name: 'placeholderSystemNote',
props: {
note: {
type: Object,
required: true,
},
},
};
</script>
<template>
<li class="note system-note timeline-entry being-posted fade-in-half">
<div class="timeline-entry-inner">
<div class="timeline-content">
<em>{{note.body}}</em>
</div>
</div>
</li>
</template>

View File

@ -0,0 +1,55 @@
<script>
import { mapGetters } from 'vuex';
import iconsMap from './issue_note_icons';
import issueNoteHeader from './issue_note_header.vue';
export default {
name: 'systemNote',
props: {
note: {
type: Object,
required: true,
},
},
components: {
issueNoteHeader,
},
computed: {
...mapGetters([
'targetNoteHash',
]),
noteAnchorId() {
return `note_${this.note.id}`;
},
isTargetNote() {
return this.targetNoteHash === this.noteAnchorId;
},
},
created() {
this.svg = iconsMap[this.note.system_note_icon_name];
},
};
</script>
<template>
<li
:id="noteAnchorId"
:class="{ target: isTargetNote }"
class="note system-note timeline-entry">
<div class="timeline-entry-inner">
<div
class="timeline-icon"
v-html="svg">
</div>
<div class="timeline-content">
<div class="note-header">
<issue-note-header
:author="note.author"
:created-at="note.created_at"
:note-id="note.id"
:action-text-html="note.note_html" />
</div>
</div>
</div>
</li>
</template>

View File

@ -0,0 +1,11 @@
export const DISCUSSION_NOTE = 'DiscussionNote';
export const DISCUSSION = 'discussion';
export const NOTE = 'note';
export const SYSTEM_NOTE = 'systemNote';
export const COMMENT = 'comment';
export const OPENED = 'opened';
export const REOPENED = 'reopened';
export const CLOSED = 'closed';
export const EMOJI_THUMBSUP = 'thumbsup';
export const EMOJI_THUMBSDOWN = 'thumbsdown';
export const NOTEABLE_TYPE = 'Issue';

View File

@ -0,0 +1,3 @@
import Vue from 'vue';
export default new Vue();

View File

@ -0,0 +1,35 @@
import Vue from 'vue';
import issueNotesApp from './components/issue_notes_app.vue';
document.addEventListener('DOMContentLoaded', () => new Vue({
el: '#js-vue-notes',
components: {
issueNotesApp,
},
data() {
const notesDataset = document.getElementById('js-vue-notes').dataset;
return {
issueData: JSON.parse(notesDataset.issueData),
currentUserData: JSON.parse(notesDataset.currentUserData),
notesData: {
lastFetchedAt: notesDataset.lastFetchedAt,
discussionsPath: notesDataset.discussionsPath,
newSessionPath: notesDataset.newSessionPath,
registerPath: notesDataset.registerPath,
notesPath: notesDataset.notesPath,
markdownDocsPath: notesDataset.markdownDocsPath,
quickActionsDocsPath: notesDataset.quickActionsDocsPath,
},
};
},
render(createElement) {
return createElement('issue-notes-app', {
props: {
issueData: this.issueData,
notesData: this.notesData,
userData: this.currentUserData,
},
});
},
}));

View File

@ -0,0 +1,16 @@
/* globals Autosave */
import '../../autosave';
export default {
methods: {
initAutoSave() {
this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', 'Issue', this.note.id], 'issue');
},
resetAutoSave() {
this.autosave.reset();
},
setAutoSave() {
this.autosave.save();
},
},
};

View File

@ -0,0 +1,35 @@
import Vue from 'vue';
import VueResource from 'vue-resource';
Vue.use(VueResource);
export default {
fetchNotes(endpoint) {
return Vue.http.get(endpoint);
},
deleteNote(endpoint) {
return Vue.http.delete(endpoint);
},
replyToDiscussion(endpoint, data) {
return Vue.http.post(endpoint, data, { emulateJSON: true });
},
updateNote(endpoint, data) {
return Vue.http.put(endpoint, data, { emulateJSON: true });
},
createNewNote(endpoint, data) {
return Vue.http.post(endpoint, data, { emulateJSON: true });
},
poll(data = {}) {
const { endpoint, lastFetchedAt } = data;
const options = {
headers: {
'X-Last-Fetched-At': lastFetchedAt,
},
};
return Vue.http.get(endpoint, options);
},
toggleAward(endpoint, data) {
return Vue.http.post(endpoint, data, { emulateJSON: true });
},
};

View File

@ -0,0 +1,217 @@
/* global Flash */
import Visibility from 'visibilityjs';
import Poll from '../../lib/utils/poll';
import * as types from './mutation_types';
import * as utils from './utils';
import * as constants from '../constants';
import service from '../services/issue_notes_service';
import loadAwardsHandler from '../../awards_handler';
import sidebarTimeTrackingEventHub from '../../sidebar/event_hub';
let eTagPoll;
export const setNotesData = ({ commit }, data) => commit(types.SET_NOTES_DATA, data);
export const setIssueData = ({ commit }, data) => commit(types.SET_ISSUE_DATA, data);
export const setUserData = ({ commit }, data) => commit(types.SET_USER_DATA, data);
export const setLastFetchedAt = ({ commit }, data) => commit(types.SET_LAST_FETCHED_AT, data);
export const setInitialNotes = ({ commit }, data) => commit(types.SET_INITIAL_NOTES, data);
export const setTargetNoteHash = ({ commit }, data) => commit(types.SET_TARGET_NOTE_HASH, data);
export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data);
export const fetchNotes = ({ commit }, path) => service
.fetchNotes(path)
.then(res => res.json())
.then((res) => {
commit(types.SET_INITIAL_NOTES, res);
});
export const deleteNote = ({ commit }, note) => service
.deleteNote(note.path)
.then(() => {
commit(types.DELETE_NOTE, note);
});
export const updateNote = ({ commit }, { endpoint, note }) => service
.updateNote(endpoint, note)
.then(res => res.json())
.then((res) => {
commit(types.UPDATE_NOTE, res);
});
export const replyToDiscussion = ({ commit }, { endpoint, data }) => service
.replyToDiscussion(endpoint, data)
.then(res => res.json())
.then((res) => {
commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res);
return res;
});
export const createNewNote = ({ commit }, { endpoint, data }) => service
.createNewNote(endpoint, data)
.then(res => res.json())
.then((res) => {
if (!res.errors) {
commit(types.ADD_NEW_NOTE, res);
}
return res;
});
export const removePlaceholderNotes = ({ commit }) =>
commit(types.REMOVE_PLACEHOLDER_NOTES);
export const saveNote = ({ commit, dispatch }, noteData) => {
const { note } = noteData.data.note;
let placeholderText = note;
const hasQuickActions = utils.hasQuickActions(placeholderText);
const replyId = noteData.data.in_reply_to_discussion_id;
const methodToDispatch = replyId ? 'replyToDiscussion' : 'createNewNote';
commit(types.REMOVE_PLACEHOLDER_NOTES); // remove previous placeholders
$('.notes-form .flash-container').hide(); // hide previous flash notification
if (hasQuickActions) {
placeholderText = utils.stripQuickActions(placeholderText);
}
if (placeholderText.length) {
commit(types.SHOW_PLACEHOLDER_NOTE, {
noteBody: placeholderText,
replyId,
});
}
if (hasQuickActions) {
commit(types.SHOW_PLACEHOLDER_NOTE, {
isSystemNote: true,
noteBody: utils.getQuickActionText(note),
replyId,
});
}
return dispatch(methodToDispatch, noteData)
.then((res) => {
const { errors } = res;
const commandsChanges = res.commands_changes;
if (hasQuickActions && errors && Object.keys(errors).length) {
eTagPoll.makeRequest();
$('.js-gfm-input').trigger('clear-commands-cache.atwho');
Flash('Commands applied', 'notice', $(noteData.flashContainer));
}
if (commandsChanges) {
if (commandsChanges.emoji_award) {
const votesBlock = $('.js-awards-block').eq(0);
loadAwardsHandler()
.then((awardsHandler) => {
awardsHandler.addAwardToEmojiBar(votesBlock, commandsChanges.emoji_award);
awardsHandler.scrollToAwards();
})
.catch(() => {
Flash(
'Something went wrong while adding your award. Please try again.',
null,
$(noteData.flashContainer),
);
});
}
if (commandsChanges.spend_time != null || commandsChanges.time_estimate != null) {
sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', res);
}
}
if (errors && errors.commands_only) {
Flash(errors.commands_only, 'notice', $(noteData.flashContainer));
}
commit(types.REMOVE_PLACEHOLDER_NOTES);
return res;
});
};
const pollSuccessCallBack = (resp, commit, state, getters) => {
if (resp.notes && resp.notes.length) {
const { notesById } = getters;
resp.notes.forEach((note) => {
if (notesById[note.id]) {
commit(types.UPDATE_NOTE, note);
} else if (note.type === constants.DISCUSSION_NOTE) {
const discussion = utils.findNoteObjectById(state.notes, note.discussion_id);
if (discussion) {
commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note);
} else {
commit(types.ADD_NEW_NOTE, note);
}
} else {
commit(types.ADD_NEW_NOTE, note);
}
});
}
commit(types.SET_LAST_FETCHED_AT, resp.lastFetchedAt);
return resp;
};
export const poll = ({ commit, state, getters }) => {
const requestData = { endpoint: state.notesData.notesPath, lastFetchedAt: state.lastFetchedAt };
eTagPoll = new Poll({
resource: service,
method: 'poll',
data: requestData,
successCallback: resp => resp.json()
.then(data => pollSuccessCallBack(data, commit, state, getters)),
errorCallback: () => Flash('Something went wrong while fetching latest comments.'),
});
if (!Visibility.hidden()) {
eTagPoll.makeRequest();
} else {
service.poll(requestData);
}
Visibility.change(() => {
if (!Visibility.hidden()) {
eTagPoll.restart();
} else {
eTagPoll.stop();
}
});
};
export const fetchData = ({ commit, state, getters }) => {
const requestData = { endpoint: state.notesData.notesPath, lastFetchedAt: state.lastFetchedAt };
service.poll(requestData)
.then(resp => resp.json)
.then(data => pollSuccessCallBack(data, commit, state, getters))
.catch(() => Flash('Something went wrong while fetching latest comments.'));
};
export const toggleAward = ({ commit, state, getters, dispatch }, { awardName, noteId }) => {
commit(types.TOGGLE_AWARD, { awardName, note: getters.notesById[noteId] });
};
export const toggleAwardRequest = ({ commit, getters, dispatch }, data) => {
const { endpoint, awardName } = data;
return service
.toggleAward(endpoint, { name: awardName })
.then(res => res.json())
.then(() => {
dispatch('toggleAward', data);
});
};
export const scrollToNoteIfNeeded = (context, el) => {
if (!gl.utils.isInViewport(el[0])) {
gl.utils.scrollToElement(el);
}
};

View File

@ -0,0 +1,31 @@
import _ from 'underscore';
export const notes = state => state.notes;
export const targetNoteHash = state => state.targetNoteHash;
export const getNotesData = state => state.notesData;
export const getNotesDataByProp = state => prop => state.notesData[prop];
export const getIssueData = state => state.issueData;
export const getIssueDataByProp = state => prop => state.issueData[prop];
export const getUserData = state => state.userData || {};
export const getUserDataByProp = state => prop => state.userData && state.userData[prop];
export const notesById = state => state.notes.reduce((acc, note) => {
note.notes.every(n => Object.assign(acc, { [n.id]: n }));
return acc;
}, {});
const reverseNotes = array => array.slice(0).reverse();
const isLastNote = (note, state) => !note.system &&
state.userData && note.author &&
note.author.id === state.userData.id;
export const getCurrentUserLastNote = state => _.flatten(
reverseNotes(state.notes)
.map(note => reverseNotes(note.notes)),
).find(el => isLastNote(el, state));
export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes)
.find(el => isLastNote(el, state));

View File

@ -0,0 +1,23 @@
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
Vue.use(Vuex);
export default new Vuex.Store({
state: {
notes: [],
targetNoteHash: null,
lastFetchedAt: null,
// holds endpoints and permissions provided through haml
notesData: {},
userData: {},
issueData: {},
},
actions,
getters,
mutations,
});

View File

@ -0,0 +1,14 @@
export const ADD_NEW_NOTE = 'ADD_NEW_NOTE';
export const ADD_NEW_REPLY_TO_DISCUSSION = 'ADD_NEW_REPLY_TO_DISCUSSION';
export const DELETE_NOTE = 'DELETE_NOTE';
export const REMOVE_PLACEHOLDER_NOTES = 'REMOVE_PLACEHOLDER_NOTES';
export const SET_NOTES_DATA = 'SET_NOTES_DATA';
export const SET_ISSUE_DATA = 'SET_ISSUE_DATA';
export const SET_USER_DATA = 'SET_USER_DATA';
export const SET_INITIAL_NOTES = 'SET_INITIAL_NOTES';
export const SET_LAST_FETCHED_AT = 'SET_LAST_FETCHED_AT';
export const SET_TARGET_NOTE_HASH = 'SET_TARGET_NOTE_HASH';
export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE';
export const TOGGLE_AWARD = 'TOGGLE_AWARD';
export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION';
export const UPDATE_NOTE = 'UPDATE_NOTE';

View File

@ -0,0 +1,151 @@
import * as utils from './utils';
import * as types from './mutation_types';
import * as constants from '../constants';
export default {
[types.ADD_NEW_NOTE](state, note) {
const { discussion_id, type } = note;
const noteData = {
expanded: true,
id: discussion_id,
individual_note: !(type === constants.DISCUSSION_NOTE),
notes: [note],
reply_id: discussion_id,
};
state.notes.push(noteData);
},
[types.ADD_NEW_REPLY_TO_DISCUSSION](state, note) {
const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id);
if (noteObj) {
noteObj.notes.push(note);
}
},
[types.DELETE_NOTE](state, note) {
const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id);
if (noteObj.individual_note) {
state.notes.splice(state.notes.indexOf(noteObj), 1);
} else {
const comment = utils.findNoteObjectById(noteObj.notes, note.id);
noteObj.notes.splice(noteObj.notes.indexOf(comment), 1);
if (!noteObj.notes.length) {
state.notes.splice(state.notes.indexOf(noteObj), 1);
}
}
},
[types.REMOVE_PLACEHOLDER_NOTES](state) {
const { notes } = state;
for (let i = notes.length - 1; i >= 0; i -= 1) {
const note = notes[i];
const children = note.notes;
if (children.length && !note.individual_note) { // remove placeholder from discussions
for (let j = children.length - 1; j >= 0; j -= 1) {
if (children[j].isPlaceholderNote) {
children.splice(j, 1);
}
}
} else if (note.isPlaceholderNote) { // remove placeholders from state root
notes.splice(i, 1);
}
}
},
[types.SET_NOTES_DATA](state, data) {
Object.assign(state, { notesData: data });
},
[types.SET_ISSUE_DATA](state, data) {
Object.assign(state, { issueData: data });
},
[types.SET_USER_DATA](state, data) {
Object.assign(state, { userData: data });
},
[types.SET_INITIAL_NOTES](state, notesData) {
const notes = [];
notesData.forEach((note) => {
// To support legacy notes, should be very rare case.
if (note.individual_note && note.notes.length > 1) {
note.notes.forEach((n) => {
const nn = Object.assign({}, note);
nn.notes = [n]; // override notes array to only have one item to mimick individual_note
notes.push(nn);
});
} else {
notes.push(note);
}
});
Object.assign(state, { notes });
},
[types.SET_LAST_FETCHED_AT](state, fetchedAt) {
Object.assign(state, { lastFetchedAt: fetchedAt });
},
[types.SET_TARGET_NOTE_HASH](state, hash) {
Object.assign(state, { targetNoteHash: hash });
},
[types.SHOW_PLACEHOLDER_NOTE](state, data) {
let notesArr = state.notes;
if (data.replyId) {
notesArr = utils.findNoteObjectById(notesArr, data.replyId).notes;
}
notesArr.push({
individual_note: true,
isPlaceholderNote: true,
placeholderType: data.isSystemNote ? constants.SYSTEM_NOTE : constants.NOTE,
notes: [
{
body: data.noteBody,
},
],
});
},
[types.TOGGLE_AWARD](state, data) {
const { awardName, note } = data;
const { id, name, username } = state.userData;
const hasEmojiAwardedByCurrentUser = note.award_emoji
.filter(emoji => emoji.name === data.awardName && emoji.user.id === id);
if (hasEmojiAwardedByCurrentUser.length) {
// If current user has awarded this emoji, remove it.
note.award_emoji.splice(note.award_emoji.indexOf(hasEmojiAwardedByCurrentUser[0]), 1);
} else {
note.award_emoji.push({
name: awardName,
user: { id, name, username },
});
}
},
[types.TOGGLE_DISCUSSION](state, { discussionId }) {
const discussion = utils.findNoteObjectById(state.notes, discussionId);
discussion.expanded = !discussion.expanded;
},
[types.UPDATE_NOTE](state, note) {
const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id);
if (noteObj.individual_note) {
noteObj.notes.splice(0, 1, note);
} else {
const comment = utils.findNoteObjectById(noteObj.notes, note.id);
noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note);
}
},
};

View File

@ -0,0 +1,31 @@
import AjaxCache from '~/lib/utils/ajax_cache';
const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm;
export const findNoteObjectById = (notes, id) => notes.filter(n => n.id === id)[0];
export const getQuickActionText = (note) => {
let text = 'Applying command';
const quickActions = AjaxCache.get(gl.GfmAutoComplete.dataSources.commands) || [];
const executedCommands = quickActions.filter((command) => {
const commandRegex = new RegExp(`/${command.name}`);
return commandRegex.test(note);
});
if (executedCommands && executedCommands.length) {
if (executedCommands.length > 1) {
text = 'Applying multiple commands';
} else {
const commandDescription = executedCommands[0].description.toLowerCase();
text = `Applying command to ${commandDescription}`;
}
}
return text;
};
export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note);
export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim();

View File

@ -72,7 +72,7 @@
};
</script>
<template>
<div>
<div class="ci-job-dropdown-container">
<button
v-tooltip
type="button"

View File

@ -75,7 +75,7 @@
};
</script>
<template>
<div>
<div class="ci-job-component">
<a
v-tooltip
v-if="job.status.details_path"

View File

@ -26,7 +26,7 @@
};
</script>
<template>
<span>
<span class="ci-job-name-component">
<ci-icon
:status="status" />

View File

@ -53,10 +53,6 @@ import Cookies from 'js-cookie';
return _this.changeProject($(e.currentTarget).val());
};
})(this));
return $('.js-projects-dropdown-toggle').on('click', function(e) {
e.preventDefault();
return $('.js-projects-dropdown').select2('open');
});
};
Project.prototype.changeProject = function(url) {

View File

@ -5,48 +5,6 @@ import ProjectSelectComboButton from './project_select_combo_button';
(function() {
this.ProjectSelect = (function() {
function ProjectSelect() {
$('.js-projects-dropdown-toggle').each(function(i, dropdown) {
var $dropdown;
$dropdown = $(dropdown);
return $dropdown.glDropdown({
filterable: true,
filterRemote: true,
search: {
fields: ['name_with_namespace']
},
data: function(term, callback) {
var finalCallback, projectsCallback;
var orderBy = $dropdown.data('order-by');
finalCallback = function(projects) {
return callback(projects);
};
if (this.includeGroups) {
projectsCallback = function(projects) {
var groupsCallback;
groupsCallback = function(groups) {
var data;
data = groups.concat(projects);
return finalCallback(data);
};
return Api.groups(term, {}, groupsCallback);
};
} else {
projectsCallback = finalCallback;
}
if (this.groupId) {
return Api.groupProjects(this.groupId, term, projectsCallback);
} else {
return Api.projects(term, { order_by: orderBy }, projectsCallback);
}
},
url: function(project) {
return project.web_url;
},
text: function(project) {
return project.name_with_namespace;
}
});
});
$('.ajax-project-select').each(function(i, select) {
var placeholder;
this.groupId = $(select).data('group-id');

View File

@ -0,0 +1,157 @@
<script>
import bs from '../../breakpoints';
import eventHub from '../event_hub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import projectsListFrequent from './projects_list_frequent.vue';
import projectsListSearch from './projects_list_search.vue';
import search from './search.vue';
export default {
components: {
search,
loadingIcon,
projectsListFrequent,
projectsListSearch,
},
props: {
currentProject: {
type: Object,
required: true,
},
store: {
type: Object,
required: true,
},
service: {
type: Object,
required: true,
},
},
data() {
return {
isLoadingProjects: false,
isFrequentsListVisible: false,
isSearchListVisible: false,
isLocalStorageFailed: false,
isSearchFailed: false,
searchQuery: '',
};
},
computed: {
frequentProjects() {
return this.store.getFrequentProjects();
},
searchProjects() {
return this.store.getSearchedProjects();
},
},
methods: {
toggleFrequentProjectsList(state) {
this.isLoadingProjects = !state;
this.isSearchListVisible = !state;
this.isFrequentsListVisible = state;
},
toggleSearchProjectsList(state) {
this.isLoadingProjects = !state;
this.isFrequentsListVisible = !state;
this.isSearchListVisible = state;
},
toggleLoader(state) {
this.isFrequentsListVisible = !state;
this.isSearchListVisible = !state;
this.isLoadingProjects = state;
},
fetchFrequentProjects() {
const screenSize = bs.getBreakpointSize();
if (this.searchQuery && (screenSize !== 'sm' && screenSize !== 'xs')) {
this.toggleSearchProjectsList(true);
} else {
this.toggleLoader(true);
this.isLocalStorageFailed = false;
const projects = this.service.getFrequentProjects();
if (projects) {
this.toggleFrequentProjectsList(true);
this.store.setFrequentProjects(projects);
} else {
this.isLocalStorageFailed = true;
this.toggleFrequentProjectsList(true);
this.store.setFrequentProjects([]);
}
}
},
fetchSearchedProjects(searchQuery) {
this.searchQuery = searchQuery;
this.toggleLoader(true);
this.service.getSearchedProjects(this.searchQuery)
.then(res => res.json())
.then((results) => {
this.toggleSearchProjectsList(true);
this.store.setSearchedProjects(results);
})
.catch(() => {
this.isSearchFailed = true;
this.toggleSearchProjectsList(true);
});
},
logCurrentProjectAccess() {
this.service.logProjectAccess(this.currentProject);
},
handleSearchClear() {
this.searchQuery = '';
this.toggleFrequentProjectsList(true);
this.store.clearSearchedProjects();
},
handleSearchFailure() {
this.isSearchFailed = true;
this.toggleSearchProjectsList(true);
},
},
created() {
if (this.currentProject.id) {
this.logCurrentProjectAccess();
}
eventHub.$on('dropdownOpen', this.fetchFrequentProjects);
eventHub.$on('searchProjects', this.fetchSearchedProjects);
eventHub.$on('searchCleared', this.handleSearchClear);
eventHub.$on('searchFailed', this.handleSearchFailure);
},
beforeDestroy() {
eventHub.$off('dropdownOpen', this.fetchFrequentProjects);
eventHub.$off('searchProjects', this.fetchSearchedProjects);
eventHub.$off('searchCleared', this.handleSearchClear);
eventHub.$off('searchFailed', this.handleSearchFailure);
},
};
</script>
<template>
<div>
<search/>
<loading-icon
class="loading-animation prepend-top-20"
size="2"
v-if="isLoadingProjects"
:label="s__('ProjectsDropdown|Loading projects')"
/>
<div
class="section-header"
v-if="isFrequentsListVisible"
>
{{ s__('ProjectsDropdown|Frequently visited') }}
</div>
<projects-list-frequent
v-if="isFrequentsListVisible"
:local-storage-failed="isLocalStorageFailed"
:projects="frequentProjects"
/>
<projects-list-search
v-if="isSearchListVisible"
:search-failed="isSearchFailed"
:matcher="searchQuery"
:projects="searchProjects"
/>
</div>
</template>

View File

@ -0,0 +1,57 @@
<script>
import { s__ } from '../../locale';
import projectsListItem from './projects_list_item.vue';
export default {
components: {
projectsListItem,
},
props: {
projects: {
type: Array,
required: true,
},
localStorageFailed: {
type: Boolean,
required: true,
},
},
computed: {
isListEmpty() {
return this.projects.length === 0;
},
listEmptyMessage() {
return this.localStorageFailed ?
s__('ProjectsDropdown|This feature requires browser localStorage support') :
s__('ProjectsDropdown|Projects you visit often will appear here');
},
},
};
</script>
<template>
<div
class="projects-list-frequent-container"
>
<ul
class="list-unstyled"
>
<li
class="section-empty"
v-if="isListEmpty"
>
{{listEmptyMessage}}
</li>
<projects-list-item
v-else
v-for="(project, index) in projects"
:key="index"
:project-id="project.id"
:project-name="project.name"
:namespace="project.namespace"
:web-url="project.webUrl"
:avatar-url="project.avatarUrl"
/>
</ul>
</div>
</template>

View File

@ -0,0 +1,96 @@
<script>
import identicon from '../../vue_shared/components/identicon.vue';
export default {
components: {
identicon,
},
props: {
matcher: {
type: String,
required: false,
},
projectId: {
type: Number,
required: true,
},
projectName: {
type: String,
required: true,
},
namespace: {
type: String,
required: true,
},
webUrl: {
type: String,
required: true,
},
avatarUrl: {
required: true,
validator(value) {
return value === null || typeof value === 'string';
},
},
},
computed: {
hasAvatar() {
return this.avatarUrl !== null;
},
highlightedProjectName() {
if (this.matcher) {
const matcherRegEx = new RegExp(this.matcher, 'gi');
const matches = this.projectName.match(matcherRegEx);
if (matches && matches.length > 0) {
return this.projectName.replace(matches[0], `<b>${matches[0]}</b>`);
}
}
return this.projectName;
},
},
};
</script>
<template>
<li
class="projects-list-item-container"
>
<a
class="clearfix"
:href="webUrl"
>
<div
class="project-item-avatar-container"
>
<img
v-if="hasAvatar"
class="avatar s32"
:src="avatarUrl"
/>
<identicon
v-else
size-class="s32"
:entity-id=projectId
:entity-name="projectName"
/>
</div>
<div
class="project-item-metadata-container"
>
<div
class="project-title"
:title="projectName"
v-html="highlightedProjectName"
>
</div>
<div
class="project-namespace"
:title="namespace"
>
{{namespace}}
</div>
</div>
</a>
</li>
</template>

View File

@ -0,0 +1,63 @@
<script>
import { s__ } from '../../locale';
import projectsListItem from './projects_list_item.vue';
export default {
components: {
projectsListItem,
},
props: {
matcher: {
type: String,
required: true,
},
projects: {
type: Array,
required: true,
},
searchFailed: {
type: Boolean,
required: true,
},
},
computed: {
isListEmpty() {
return this.projects.length === 0;
},
listEmptyMessage() {
return this.searchFailed ?
s__('ProjectsDropdown|Something went wrong on our end.') :
s__('ProjectsDropdown|No projects matched your query');
},
},
};
</script>
<template>
<div
class="projects-list-search-container"
>
<ul
class="list-unstyled"
>
<li
v-if="isListEmpty"
:class="{ 'section-failure': searchFailed }"
class="section-empty"
>
{{ listEmptyMessage }}
</li>
<projects-list-item
v-else
v-for="(project, index) in projects"
:key="index"
:project-id="project.id"
:project-name="project.name"
:namespace="project.namespace"
:web-url="project.webUrl"
:avatar-url="project.avatarUrl"
:matcher="matcher"
/>
</ul>
</div>
</template>

View File

@ -0,0 +1,64 @@
<script>
import _ from 'underscore';
import eventHub from '../event_hub';
export default {
data() {
return {
searchQuery: '',
};
},
watch: {
searchQuery() {
this.handleInput();
},
},
methods: {
setFocus() {
this.$refs.search.focus();
},
emitSearchEvents() {
if (this.searchQuery) {
eventHub.$emit('searchProjects', this.searchQuery);
} else {
eventHub.$emit('searchCleared');
}
},
/**
* Callback function within _.debounce is intentionally
* kept as ES5 `function() {}` instead of ES6 `() => {}`
* as it otherwise messes up function context
* and component reference is no longer accessible via `this`
*/
// eslint-disable-next-line func-names
handleInput: _.debounce(function () {
this.emitSearchEvents();
}, 500),
},
mounted() {
eventHub.$on('dropdownOpen', this.setFocus);
},
beforeDestroy() {
eventHub.$off('dropdownOpen', this.setFocus);
},
};
</script>
<template>
<div
class="search-input-container hidden-xs"
>
<input
type="search"
class="form-control"
ref="search"
v-model="searchQuery"
:placeholder="s__('ProjectsDropdown|Search projects')"
/>
<i
v-if="!searchQuery"
class="search-icon fa fa-fw fa-search"
aria-hidden="true"
/>
</div>
</template>

View File

@ -0,0 +1,10 @@
export const FREQUENT_PROJECTS = {
MAX_COUNT: 20,
LIST_COUNT_DESKTOP: 5,
LIST_COUNT_MOBILE: 3,
ELIGIBLE_FREQUENCY: 3,
};
export const HOUR_IN_MS = 3600000;
export const STORAGE_KEY = 'frequent-projects';

View File

@ -0,0 +1,3 @@
import Vue from 'vue';
export default new Vue();

View File

@ -0,0 +1,68 @@
import Vue from 'vue';
import Translate from '../vue_shared/translate';
import eventHub from './event_hub';
import ProjectsService from './service/projects_service';
import ProjectsStore from './store/projects_store';
import projectsDropdownApp from './components/app.vue';
Vue.use(Translate);
document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('js-projects-dropdown');
const navEl = document.getElementById('nav-projects-dropdown');
// Don't do anything if element doesn't exist (No projects dropdown)
// This is for when the user accesses GitLab without logging in
if (!el || !navEl) {
return;
}
$(navEl).on('show.bs.dropdown', (e) => {
const dropdownEl = $(e.currentTarget).find('.projects-dropdown-menu');
dropdownEl.one('transitionend', () => {
eventHub.$emit('dropdownOpen');
});
});
// eslint-disable-next-line no-new
new Vue({
el,
components: {
projectsDropdownApp,
},
data() {
const dataset = this.$options.el.dataset;
const store = new ProjectsStore();
const service = new ProjectsService(dataset.userName);
const project = {
id: Number(dataset.projectId),
name: dataset.projectName,
namespace: dataset.projectNamespace,
webUrl: dataset.projectWebUrl,
avatarUrl: dataset.projectAvatarUrl || null,
lastAccessedOn: Date.now(),
};
return {
store,
service,
state: store.state,
currentUserName: dataset.userName,
currentProject: project,
};
},
render(createElement) {
return createElement('projects-dropdown-app', {
props: {
currentUserName: this.currentUserName,
currentProject: this.currentProject,
store: this.store,
service: this.service,
},
});
},
});
});

View File

@ -0,0 +1,132 @@
import Vue from 'vue';
import VueResource from 'vue-resource';
import bp from '../../breakpoints';
import Api from '../../api';
import AccessorUtilities from '../../lib/utils/accessor';
import { FREQUENT_PROJECTS, HOUR_IN_MS, STORAGE_KEY } from '../constants';
Vue.use(VueResource);
export default class ProjectsService {
constructor(currentUserName) {
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
this.currentUserName = currentUserName;
this.storageKey = `${this.currentUserName}/${STORAGE_KEY}`;
this.projectsPath = Vue.resource(Api.buildUrl(Api.projectsPath));
}
getSearchedProjects(searchQuery) {
return this.projectsPath.get({
simple: false,
per_page: 20,
membership: !!gon.current_user_id,
order_by: 'last_activity_at',
search: searchQuery,
});
}
getFrequentProjects() {
if (this.isLocalStorageAvailable) {
return this.getTopFrequentProjects();
}
return null;
}
logProjectAccess(project) {
let matchFound = false;
let storedFrequentProjects;
if (this.isLocalStorageAvailable) {
const storedRawProjects = localStorage.getItem(this.storageKey);
// Check if there's any frequent projects list set
if (!storedRawProjects) {
// No frequent projects list set, set one up.
storedFrequentProjects = [];
storedFrequentProjects.push({ ...project, frequency: 1 });
} else {
// Check if project is already present in frequents list
// When found, update metadata of it.
storedFrequentProjects = JSON.parse(storedRawProjects).map((projectItem) => {
if (projectItem.id === project.id) {
matchFound = true;
const diff = Math.abs(project.lastAccessedOn - projectItem.lastAccessedOn) / HOUR_IN_MS;
const updatedProject = {
...project,
frequency: projectItem.frequency,
lastAccessedOn: projectItem.lastAccessedOn,
};
// Check if duration since last access of this project
// is over an hour
if (diff > 1) {
return {
...updatedProject,
frequency: updatedProject.frequency + 1,
lastAccessedOn: Date.now(),
};
}
return {
...updatedProject,
};
}
return projectItem;
});
// Check whether currently logged project is present in frequents list
if (!matchFound) {
// We always keep size of frequents collection to 20 projects
// out of which only 5 projects with
// highest value of `frequency` and most recent `lastAccessedOn`
// are shown in projects dropdown
if (storedFrequentProjects.length === FREQUENT_PROJECTS.MAX_COUNT) {
storedFrequentProjects.shift(); // Remove an item from head of array
}
storedFrequentProjects.push({ ...project, frequency: 1 });
}
}
localStorage.setItem(this.storageKey, JSON.stringify(storedFrequentProjects));
}
}
getTopFrequentProjects() {
const storedFrequentProjects = JSON.parse(localStorage.getItem(this.storageKey));
let frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_DESKTOP;
if (!storedFrequentProjects) {
return [];
}
if (bp.getBreakpointSize() === 'sm' ||
bp.getBreakpointSize() === 'xs') {
frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_MOBILE;
}
const frequentProjects = storedFrequentProjects
.filter(project => project.frequency >= FREQUENT_PROJECTS.ELIGIBLE_FREQUENCY);
// Sort all frequent projects in decending order of frequency
// and then by lastAccessedOn with recent most first
frequentProjects.sort((projectA, projectB) => {
if (projectA.frequency < projectB.frequency) {
return 1;
} else if (projectA.frequency > projectB.frequency) {
return -1;
} else if (projectA.lastAccessedOn < projectB.lastAccessedOn) {
return 1;
} else if (projectA.lastAccessedOn > projectB.lastAccessedOn) {
return -1;
}
return 0;
});
return _.first(frequentProjects, frequentProjectsCount);
}
}

View File

@ -0,0 +1,33 @@
export default class ProjectsStore {
constructor() {
this.state = {};
this.state.frequentProjects = [];
this.state.searchedProjects = [];
}
setFrequentProjects(rawProjects) {
this.state.frequentProjects = rawProjects;
}
getFrequentProjects() {
return this.state.frequentProjects;
}
setSearchedProjects(rawProjects) {
this.state.searchedProjects = rawProjects.map(rawProject => ({
id: rawProject.id,
name: rawProject.name,
namespace: rawProject.name_with_namespace,
webUrl: rawProject.web_url,
avatarUrl: rawProject.avatar_url,
}));
}
getSearchedProjects() {
return this.state.searchedProjects;
}
clearSearchedProjects() {
this.state.searchedProjects = [];
}
}

View File

@ -157,11 +157,16 @@ import SidebarHeightManager from './sidebar_height_manager';
Sidebar.prototype.openDropdown = function(blockOrName) {
var $block;
$block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName;
$block.find('.edit-link').trigger('click');
if (!this.isOpen()) {
this.setCollapseAfterUpdate($block);
return this.toggleSidebar('open');
this.toggleSidebar('open');
}
// Wait for the sidebar to trigger('click') open
// so it doesn't cause our dropdown to close preemptively
setTimeout(() => {
$block.find('.js-sidebar-dropdown-toggle').trigger('click');
});
};
Sidebar.prototype.setCollapseAfterUpdate = function($block) {

View File

@ -20,7 +20,7 @@ import './shortcuts_navigation';
Mousetrap.bind('m', this.openSidebarDropdown.bind(this, 'milestone'));
Mousetrap.bind('r', (function(_this) {
return function() {
_this.replyWithSelectedText();
_this.replyWithSelectedText(isMergeRequest);
return false;
};
})(this));
@ -38,9 +38,15 @@ import './shortcuts_navigation';
}
}
ShortcutsIssuable.prototype.replyWithSelectedText = function() {
ShortcutsIssuable.prototype.replyWithSelectedText = function(isMergeRequest) {
var quote, documentFragment, el, selected, separator;
var replyField = $('.js-main-target-form #note_note');
let replyField;
if (isMergeRequest) {
replyField = $('.js-main-target-form #note_note');
} else {
replyField = $('.js-main-target-form .js-vue-comment-form');
}
documentFragment = window.gl.utils.getSelectedFragment();
if (!documentFragment) {
@ -57,6 +63,7 @@ import './shortcuts_navigation';
quote = _.map(selected.split("\n"), function(val) {
return ("> " + val).trim() + "\n";
});
// If replyField already has some content, add a newline before our quote
separator = replyField.val().trim() !== "" && "\n\n" || '';
replyField.val(function(a, current) {
@ -64,7 +71,7 @@ import './shortcuts_navigation';
});
// Trigger autosave
replyField.trigger('input');
replyField.trigger('input').trigger('change');
// Trigger autosize
var event = document.createEvent('Event');

View File

@ -36,7 +36,7 @@ export default {
/>
<a
v-if="editable"
class="edit-link pull-right"
class="js-sidebar-dropdown-toggle edit-link pull-right"
href="#"
>
Edit

View File

@ -6,6 +6,7 @@ import timeTracker from './time_tracker';
import Store from '../../stores/sidebar_store';
import Mediator from '../../sidebar_mediator';
import eventHub from '../../event_hub';
export default {
data() {
@ -20,6 +21,9 @@ export default {
methods: {
listenForQuickActions() {
$(document).on('ajax:success', '.gfm-form', this.quickActionListened);
eventHub.$on('timeTrackingUpdated', (data) => {
this.quickActionListened(null, data);
});
},
quickActionListened(e, data) {
const subscribedCommands = ['spend_time', 'time_estimate'];

View File

@ -0,0 +1,85 @@
/* global Flash */
function isValidProjectId(id) {
return id > 0;
}
class SidebarMoveIssue {
constructor(mediator, dropdownToggle, confirmButton) {
this.mediator = mediator;
this.$dropdownToggle = $(dropdownToggle);
this.$confirmButton = $(confirmButton);
this.onConfirmClickedWrapper = this.onConfirmClicked.bind(this);
}
init() {
this.initDropdown();
this.addEventListeners();
}
destroy() {
this.removeEventListeners();
}
initDropdown() {
this.$dropdownToggle.glDropdown({
search: {
fields: ['name_with_namespace'],
},
showMenuAbove: true,
selectable: true,
filterable: true,
filterRemote: true,
multiSelect: false,
// Keep the dropdown open after selecting an option
shouldPropagate: false,
data: (searchTerm, callback) => {
this.mediator.fetchAutocompleteProjects(searchTerm)
.then(callback)
.catch(() => new Flash('An error occured while fetching projects autocomplete.'));
},
renderRow: project => `
<li>
<a href="#" class="js-move-issue-dropdown-item">
${project.name_with_namespace}
</a>
</li>
`,
clicked: (options) => {
const project = options.selectedObj;
const selectedProjectId = options.isMarking ? project.id : 0;
this.mediator.setMoveToProjectId(selectedProjectId);
this.$confirmButton.attr('disabled', !isValidProjectId(selectedProjectId));
},
});
}
addEventListeners() {
this.$confirmButton.on('click', this.onConfirmClickedWrapper);
}
removeEventListeners() {
this.$confirmButton.off('click', this.onConfirmClickedWrapper);
}
onConfirmClicked() {
if (isValidProjectId(this.mediator.store.moveToProjectId)) {
this.$confirmButton
.disable()
.addClass('is-loading');
this.mediator.moveIssue()
.catch(() => {
Flash('An error occured while moving the issue.');
this.$confirmButton
.enable()
.removeClass('is-loading');
});
}
}
}
export default SidebarMoveIssue;

View File

@ -4,9 +4,11 @@ import VueResource from 'vue-resource';
Vue.use(VueResource);
export default class SidebarService {
constructor(endpoint) {
constructor(endpointMap) {
if (!SidebarService.singleton) {
this.endpoint = endpoint;
this.endpoint = endpointMap.endpoint;
this.moveIssueEndpoint = endpointMap.moveIssueEndpoint;
this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint;
SidebarService.singleton = this;
}
@ -25,4 +27,18 @@ export default class SidebarService {
emulateJSON: true,
});
}
getProjectsAutocomplete(searchTerm) {
return Vue.http.get(this.projectsAutocompleteEndpoint, {
params: {
search: searchTerm,
},
});
}
moveIssue(moveToProjectId) {
return Vue.http.post(this.moveIssueEndpoint, {
move_to_project_id: moveToProjectId,
});
}
}

View File

@ -2,6 +2,7 @@ import Vue from 'vue';
import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
import sidebarAssignees from './components/assignees/sidebar_assignees';
import confidential from './components/confidential/confidential_issue_sidebar.vue';
import SidebarMoveIssue from './lib/sidebar_move_issue';
import Mediator from './sidebar_mediator';
@ -31,6 +32,12 @@ function domContentLoaded() {
service: mediator.service,
},
}).$mount(confidentialEl);
new SidebarMoveIssue(
mediator,
$('.js-move-issue'),
$('.js-move-issue-confirmation-button'),
).init();
}
new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker');

View File

@ -7,7 +7,11 @@ export default class SidebarMediator {
constructor(options) {
if (!SidebarMediator.singleton) {
this.store = new Store(options);
this.service = new Service(options.endpoint);
this.service = new Service({
endpoint: options.endpoint,
moveIssueEndpoint: options.moveIssueEndpoint,
projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint,
});
SidebarMediator.singleton = this;
}
@ -26,6 +30,10 @@ export default class SidebarMediator {
return this.service.update(field, selected.length === 0 ? [0] : selected);
}
setMoveToProjectId(projectId) {
this.store.setMoveToProjectId(projectId);
}
fetch() {
this.service.get()
.then(response => response.json())
@ -35,4 +43,23 @@ export default class SidebarMediator {
})
.catch(() => new Flash('Error occured when fetching sidebar data'));
}
fetchAutocompleteProjects(searchTerm) {
return this.service.getProjectsAutocomplete(searchTerm)
.then(response => response.json())
.then((data) => {
this.store.setAutocompleteProjects(data);
return this.store.autocompleteProjects;
});
}
moveIssue() {
return this.service.moveIssue(this.store.moveToProjectId)
.then(response => response.json())
.then((data) => {
if (location.pathname !== data.web_url) {
gl.utils.visitUrl(data.web_url);
}
});
}
}

View File

@ -13,6 +13,8 @@ export default class SidebarStore {
this.isFetching = {
assignees: true,
};
this.autocompleteProjects = [];
this.moveToProjectId = 0;
SidebarStore.singleton = this;
}
@ -53,4 +55,12 @@ export default class SidebarStore {
removeAllAssignees() {
this.assignees = [];
}
setAutocompleteProjects(projects) {
this.autocompleteProjects = projects;
}
setMoveToProjectId(moveToProjectId) {
this.moveToProjectId = moveToProjectId;
}
}

View File

@ -75,18 +75,20 @@ export default {
class="btn btn-small inline">
Check out branch
</a>
<span class="dropdown inline prepend-left-10">
<span class="dropdown prepend-left-10">
<a
class="btn btn-xs dropdown-toggle"
class="btn btn-small inline dropdown-toggle"
data-toggle="dropdown"
aria-label="Download as"
role="button">
<i
class="fa fa-download"
aria-hidden="true" />
aria-hidden="true">
</i>
<i
class="fa fa-caret-down"
aria-hidden="true" />
aria-hidden="true">
</i>
</a>
<ul class="dropdown-menu dropdown-menu-align-right">
<li>

View File

@ -9,6 +9,11 @@ export default {
type: String,
required: true,
},
sizeClass: {
type: String,
required: false,
default: 's40',
},
},
computed: {
/**
@ -38,7 +43,8 @@ export default {
<template>
<div
class="avatar s40 identicon"
class="avatar identicon"
:class="sizeClass"
:style="identiconStyles">
{{identiconTitle}}
</div>

View File

@ -0,0 +1,16 @@
<script>
export default {
name: 'confidentialIssueWarning',
};
</script>
<template>
<div class="confidential-issue-warning">
<i
aria-hidden="true"
class="fa fa-eye-slash">
</i>
<span>
This is a confidential issue. Your comment will not be visible to the public.
</span>
</div>
</template>

View File

@ -5,19 +5,30 @@
export default {
props: {
markdownPreviewUrl: {
markdownPreviewPath: {
type: String,
required: false,
default: '',
},
markdownDocs: {
markdownDocsPath: {
type: String,
required: true,
},
addSpacingClasses: {
type: Boolean,
required: false,
default: true,
},
quickActionsDocsPath: {
type: String,
required: false,
},
},
data() {
return {
markdownPreview: '',
referencedCommands: '',
referencedUsers: '',
markdownPreviewLoading: false,
previewMarkdown: false,
};
@ -26,36 +37,49 @@
markdownHeader,
markdownToolbar,
},
computed: {
shouldShowReferencedUsers() {
const referencedUsersThreshold = 10;
return this.referencedUsers.length >= referencedUsersThreshold;
},
},
methods: {
toggleMarkdownPreview() {
this.previewMarkdown = !this.previewMarkdown;
/*
Can't use `$refs` as the component is technically in the parent component
so we access the VNode & then get the element
*/
const text = this.$slots.textarea[0].elm.value;
if (!this.previewMarkdown) {
this.markdownPreview = '';
} else {
} else if (text) {
this.markdownPreviewLoading = true;
this.$http.post(
this.markdownPreviewUrl,
{
/*
Can't use `$refs` as the component is technically in the parent component
so we access the VNode & then get the element
*/
text: this.$slots.textarea[0].elm.value,
},
)
.then(resp => resp.json())
.then((data) => {
this.markdownPreviewLoading = false;
this.markdownPreview = data.body;
this.$nextTick(() => {
$(this.$refs['markdown-preview']).renderGFM();
});
})
.catch(() => new Flash('Error loading markdown preview'));
this.$http.post(this.markdownPreviewPath, { text })
.then(resp => resp.json())
.then((data) => {
this.renderMarkdown(data);
})
.catch(() => new Flash('Error loading markdown preview'));
} else {
this.renderMarkdown();
}
},
renderMarkdown(data = {}) {
this.markdownPreviewLoading = false;
this.markdownPreview = data.body || 'Nothing to preview.';
if (data.references) {
this.referencedCommands = data.references.commands;
this.referencedUsers = data.references.users;
}
this.$nextTick(() => {
$(this.$refs['markdown-preview']).renderGFM();
});
},
},
mounted() {
/*
@ -74,7 +98,8 @@
<template>
<div
class="md-area prepend-top-default append-bottom-default js-vue-markdown-field"
class="md-area js-vue-markdown-field"
:class="{ 'prepend-top-default append-bottom-default': addSpacingClasses }"
ref="gl-form">
<markdown-header
:preview-markdown="previewMarkdown"
@ -94,7 +119,9 @@
</i>
</a>
<markdown-toolbar
:markdown-docs="markdownDocs" />
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
/>
</div>
</div>
<div
@ -108,5 +135,27 @@
Loading...
</span>
</div>
<template v-if="previewMarkdown && !markdownPreviewLoading">
<div
v-if="referencedCommands"
v-html="referencedCommands"
class="referenced-commands"></div>
<div
v-if="shouldShowReferencedUsers"
class="referenced-users">
<span>
<i
class="fa fa-exclamation-triangle"
aria-hidden="true">
</i>
You are about to add
<strong>
<span class="js-referenced-users-count">
{{referencedUsers.length}}
</span>
</strong> people to the discussion. Proceed with caution.
</span>
</div>
</template>
</div>
</template>

View File

@ -1,10 +1,14 @@
<script>
export default {
props: {
markdownDocs: {
markdownDocsPath: {
type: String,
required: true,
},
quickActionsDocsPath: {
type: String,
required: false,
},
},
};
</script>
@ -12,22 +16,77 @@
<template>
<div class="comment-toolbar clearfix">
<div class="toolbar-text">
<a
:href="markdownDocs"
target="_blank"
tabindex="-1">
Markdown is supported
</a>
<template v-if="!quickActionsDocsPath && markdownDocsPath">
<a
:href="markdownDocsPath"
target="_blank"
tabindex="-1">
Markdown is supported
</a>
</template>
<template v-if="quickActionsDocsPath && markdownDocsPath">
<a
:href="markdownDocsPath"
target="_blank"
tabindex="-1">
Markdown
</a>
and
<a
:href="quickActionsDocsPath"
target="_blank"
tabindex="-1">
quick actions
</a>
are supported
</template>
</div>
<button
class="toolbar-button markdown-selector"
type="button"
tabindex="-1">
<i
class="fa fa-file-image-o toolbar-button-icon"
aria-hidden="true">
</i>
Attach a file
</button>
<span class="uploading-container">
<span class="uploading-progress-container hide">
<i
class="fa fa-file-image-o toolbar-button-icon"
aria-hidden="true"></i>
<span class="attaching-file-message"></span>
<span class="uploading-progress">0%</span>
<span class="uploading-spinner">
<i
class="fa fa-spinner fa-spin toolbar-button-icon"
aria-hidden="true"></i>
</span>
</span>
<span class="uploading-error-container hide">
<span class="uploading-error-icon">
<i
class="fa fa-file-image-o toolbar-button-icon"
aria-hidden="true"></i>
</span>
<span class="uploading-error-message"></span>
<button
class="retry-uploading-link"
type="button">
Try again
</button>
or
<button
class="attach-new-file markdown-selector"
type="button">
attach a new file
</button>
</span>
<button
class="markdown-selector button-attach-file"
tabindex="-1"
type="button">
<i
class="fa fa-file-image-o toolbar-button-icon"
aria-hidden="true"></i>
Attach a file
</button>
<button
class="btn btn-default btn-xs hide button-cancel-uploading-files"
type="button">
Cancel
</button>
</span>
</div>
</template>

View File

@ -51,3 +51,4 @@
@import "framework/snippets";
@import "framework/memory_graph";
@import "framework/responsive-tables";
@import "framework/feature_highlight";

View File

@ -46,6 +46,15 @@
}
}
@mixin btn-svg {
svg {
height: 15px;
width: 15px;
position: relative;
top: 2px;
}
}
@mixin btn-color($light, $border-light, $normal, $border-normal, $dark, $border-dark, $color) {
background-color: $light;
border-color: $border-light;
@ -123,6 +132,7 @@
.btn {
@include btn-default;
@include btn-white;
@include btn-svg;
color: $gl-text-color;
@ -222,13 +232,6 @@
}
}
svg {
height: 15px;
width: 15px;
position: relative;
top: 2px;
}
svg,
.fa {
&:not(:last-child) {

View File

@ -95,8 +95,8 @@
.is-selected .pika-day,
.pika-day:hover,
.is-today .pika-day {
background: $gl-primary;
color: $white-light;
background: $gray-darker;
color: $gl-text-color;
box-shadow: none;
}
}

View File

@ -16,10 +16,12 @@
.prepend-left-default { margin-left: $gl-padding; }
.prepend-left-20 { margin-left: 20px; }
.append-right-5 { margin-right: 5px; }
.append-right-8 { margin-right: 8px; }
.append-right-10 { margin-right: 10px; }
.append-right-default { margin-right: $gl-padding; }
.append-right-20 { margin-right: 20px; }
.append-bottom-0 { margin-bottom: 0; }
.append-bottom-5 { margin-bottom: 5px; }
.append-bottom-10 { margin-bottom: 10px; }
.append-bottom-15 { margin-bottom: 15px; }
.append-bottom-20 { margin-bottom: 20px; }

Some files were not shown because too many files have changed in this diff Show More