Commit 64ccd184 authored by Clement Ho's avatar Clement Ho

merge master

parents d91a385f 211679e2
...@@ -55,7 +55,7 @@ stages: ...@@ -55,7 +55,7 @@ stages:
.use-pg: &use-pg .use-pg: &use-pg
services: services:
- postgres:latest - postgres:9.2
- redis:alpine - redis:alpine
- elasticsearch:5.3 - elasticsearch:5.3
...@@ -68,6 +68,7 @@ stages: ...@@ -68,6 +68,7 @@ stages:
.only-master-and-ee-or-mysql: &only-master-and-ee-or-mysql .only-master-and-ee-or-mysql: &only-master-and-ee-or-mysql
only: only:
- /mysql/ - /mysql/
- /-stable$/
- master@gitlab-org/gitlab-ce - master@gitlab-org/gitlab-ce
- master@gitlab/gitlabhq - master@gitlab/gitlabhq
- tags@gitlab-org/gitlab-ce - tags@gitlab-org/gitlab-ce
...@@ -89,7 +90,7 @@ stages: ...@@ -89,7 +90,7 @@ stages:
- JOB_NAME=( $CI_JOB_NAME ) - JOB_NAME=( $CI_JOB_NAME )
- export CI_NODE_INDEX=${JOB_NAME[-2]} - export CI_NODE_INDEX=${JOB_NAME[-2]}
- export CI_NODE_TOTAL=${JOB_NAME[-1]} - export CI_NODE_TOTAL=${JOB_NAME[-1]}
- export KNAPSACK_REPORT_PATH=knapsack/${CI_PROJECT_NAME}/${JOB_NAME[0]}_${JOB_NAME[1]}_node_${CI_NODE_INDEX}_${CI_NODE_TOTAL}_report.json - export KNAPSACK_REPORT_PATH=knapsack/${CI_PROJECT_NAME}/${JOB_NAME[0]}_node_${CI_NODE_INDEX}_${CI_NODE_TOTAL}_report.json
- export KNAPSACK_GENERATE_REPORT=true - export KNAPSACK_GENERATE_REPORT=true
- export CACHE_CLASSES=true - export CACHE_CLASSES=true
- cp ${KNAPSACK_RSPEC_SUITE_REPORT_PATH} ${KNAPSACK_REPORT_PATH} - cp ${KNAPSACK_RSPEC_SUITE_REPORT_PATH} ${KNAPSACK_REPORT_PATH}
...@@ -120,7 +121,7 @@ stages: ...@@ -120,7 +121,7 @@ stages:
- JOB_NAME=( $CI_JOB_NAME ) - JOB_NAME=( $CI_JOB_NAME )
- export CI_NODE_INDEX=${JOB_NAME[-2]} - export CI_NODE_INDEX=${JOB_NAME[-2]}
- export CI_NODE_TOTAL=${JOB_NAME[-1]} - export CI_NODE_TOTAL=${JOB_NAME[-1]}
- export KNAPSACK_REPORT_PATH=knapsack/${CI_PROJECT_NAME}/${JOB_NAME[0]}_${JOB_NAME[1]}_node_${CI_NODE_INDEX}_${CI_NODE_TOTAL}_report.json - export KNAPSACK_REPORT_PATH=knapsack/${CI_PROJECT_NAME}/${JOB_NAME[0]}_node_${CI_NODE_INDEX}_${CI_NODE_TOTAL}_report.json
- export KNAPSACK_GENERATE_REPORT=true - export KNAPSACK_GENERATE_REPORT=true
- export CACHE_CLASSES=true - export CACHE_CLASSES=true
- cp ${KNAPSACK_SPINACH_SUITE_REPORT_PATH} ${KNAPSACK_REPORT_PATH} - cp ${KNAPSACK_SPINACH_SUITE_REPORT_PATH} ${KNAPSACK_REPORT_PATH}
...@@ -154,6 +155,7 @@ stages: ...@@ -154,6 +155,7 @@ stages:
# Trigger a package build on omnibus-gitlab repository # Trigger a package build on omnibus-gitlab repository
build-package: build-package:
image: ruby:2.3-alpine
before_script: [] before_script: []
services: [] services: []
variables: variables:
...@@ -183,8 +185,8 @@ update-knapsack: ...@@ -183,8 +185,8 @@ update-knapsack:
<<: *only-canonical-masters <<: *only-canonical-masters
stage: post-test stage: post-test
script: script:
- scripts/merge-reports ${KNAPSACK_RSPEC_SUITE_REPORT_PATH} knapsack/${CI_PROJECT_NAME}/rspec_pg_node_*.json - scripts/merge-reports ${KNAPSACK_RSPEC_SUITE_REPORT_PATH} knapsack/${CI_PROJECT_NAME}/rspec-pg_node_*.json
- scripts/merge-reports ${KNAPSACK_SPINACH_SUITE_REPORT_PATH} knapsack/${CI_PROJECT_NAME}/spinach_pg_node_*.json - scripts/merge-reports ${KNAPSACK_SPINACH_SUITE_REPORT_PATH} knapsack/${CI_PROJECT_NAME}/spinach-pg_node_*.json
- '[[ -z ${KNAPSACK_S3_BUCKET} ]] || scripts/sync-reports put $KNAPSACK_S3_BUCKET $KNAPSACK_RSPEC_SUITE_REPORT_PATH $KNAPSACK_SPINACH_SUITE_REPORT_PATH' - '[[ -z ${KNAPSACK_S3_BUCKET} ]] || scripts/sync-reports put $KNAPSACK_S3_BUCKET $KNAPSACK_RSPEC_SUITE_REPORT_PATH $KNAPSACK_SPINACH_SUITE_REPORT_PATH'
- rm -f knapsack/${CI_PROJECT_NAME}/*_node_*.json - rm -f knapsack/${CI_PROJECT_NAME}/*_node_*.json
......
...@@ -971,7 +971,7 @@ RSpec/DescribeSymbol: ...@@ -971,7 +971,7 @@ RSpec/DescribeSymbol:
RSpec/DescribedClass: RSpec/DescribedClass:
Enabled: true Enabled: true
# Configuration parameters: CustomIncludeMethods. # Checks if an example group does not include any tests.
RSpec/EmptyExampleGroup: RSpec/EmptyExampleGroup:
Enabled: true Enabled: true
CustomIncludeMethods: CustomIncludeMethods:
...@@ -998,6 +998,10 @@ RSpec/ExampleWording: ...@@ -998,6 +998,10 @@ RSpec/ExampleWording:
RSpec/ExpectActual: RSpec/ExpectActual:
Enabled: true Enabled: true
# Checks for opportunities to use `expect { … }.to output`.
RSpec/ExpectOutput:
Enabled: true
# Checks the file and folder naming of the spec file. # Checks the file and folder naming of the spec file.
RSpec/FilePath: RSpec/FilePath:
Enabled: true Enabled: true
......
...@@ -18,10 +18,6 @@ RSpec/EmptyLineAfterFinalLet: ...@@ -18,10 +18,6 @@ RSpec/EmptyLineAfterFinalLet:
RSpec/EmptyLineAfterSubject: RSpec/EmptyLineAfterSubject:
Enabled: false Enabled: false
# Offense count: 3
RSpec/ExpectOutput:
Enabled: false
# Offense count: 72 # Offense count: 72
# Configuration parameters: EnforcedStyle, SupportedStyles. # Configuration parameters: EnforcedStyle, SupportedStyles.
# SupportedStyles: implicit, each, example # SupportedStyles: implicit, each, example
......
Please view this file on the master branch, on stable branches it's out of date. Please view this file on the master branch, on stable branches it's out of date.
## 9.2.2 (2017-05-25)
- No changes.
## 9.2.1 (2017-05-23) ## 9.2.1 (2017-05-23)
- No changes. - No changes.
......
...@@ -2,6 +2,11 @@ ...@@ -2,6 +2,11 @@
documentation](doc/development/changelog.md) for instructions on adding your own documentation](doc/development/changelog.md) for instructions on adding your own
entry. entry.
## 9.2.2 (2017-05-25)
- Fix issue where real time pipelines were not cached. !11615
- Make all notes use equal padding.
## 9.2.1 (2017-05-23) ## 9.2.1 (2017-05-23)
- Fix placement of note emoji on hover. - Fix placement of note emoji on hover.
......
9.2.0-pre 9.3.0-pre
/* eslint-disable no-var, wrap-iife, func-names, space-before-function-paren, camelcase, no-unused-vars, quotes, object-shorthand, one-var, one-var-declaration-per-line, prefer-arrow-callback, comma-dangle, prefer-template, no-else-return, yoda, prefer-rest-params, prefer-spread, max-len */ /* eslint-disable no-var, wrap-iife, func-names, space-before-function-paren, camelcase, no-unused-vars, quotes, object-shorthand, one-var, one-var-declaration-per-line, prefer-arrow-callback, comma-dangle, prefer-template, no-else-return, yoda, prefer-rest-params, prefer-spread, max-len */
/* global Api */ import Api from './api';
var slice = [].slice; var slice = [].slice;
......
/* eslint-disable func-names, space-before-function-paren, quotes, object-shorthand, camelcase, no-var, comma-dangle, prefer-arrow-callback, quote-props, no-param-reassign, max-len */ import $ from 'jquery';
var Api = { const Api = {
groupsPath: '/api/:version/groups.json', groupsPath: '/api/:version/groups.json',
groupPath: '/api/:version/groups/:id.json', groupPath: '/api/:version/groups/:id.json',
namespacesPath: '/api/:version/namespaces.json', namespacesPath: '/api/:version/namespaces.json',
...@@ -13,165 +13,190 @@ var Api = { ...@@ -13,165 +13,190 @@ var Api = {
ldapGroupsPath: '/api/:version/ldap/:provider/groups.json', ldapGroupsPath: '/api/:version/ldap/:provider/groups.json',
dockerfilePath: '/api/:version/templates/dockerfiles/:key', dockerfilePath: '/api/:version/templates/dockerfiles/:key',
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key', issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
group: function(group_id, callback) { usersPath: '/api/:version/users.json',
var url = Api.buildUrl(Api.groupPath) group(groupId, callback) {
.replace(':id', group_id); const url = Api.buildUrl(Api.groupPath)
return $.ajax({ .replace(':id', groupId);
url: url,
dataType: 'json'
}).done(function(group) {
return callback(group);
});
},
users: function(search, options, callback = $.noop) {
var url = Api.buildUrl('/autocomplete/users.json');
return $.ajax({ return $.ajax({
url, url,
data: $.extend({ dataType: 'json',
search, })
per_page: 20 .done(group => callback(group));
}, options),
dataType: 'json'
}).done(callback);
}, },
// Return groups list. Filtered by query // Return groups list. Filtered by query
groups: function(query, options, callback = $.noop) { groups(query, options, callback = $.noop) {
var url = Api.buildUrl(Api.groupsPath); const url = Api.buildUrl(Api.groupsPath);
return $.ajax({ return $.ajax({
url: url, url,
data: $.extend({ data: Object.assign({
search: query, search: query,
per_page: 20 per_page: 20,
}, options), }, options),
dataType: 'json' dataType: 'json',
}).done(function(groups) { })
return callback(groups); .done(groups => callback(groups));
});
}, },
// Return namespaces list. Filtered by query // Return namespaces list. Filtered by query
namespaces: function(query, callback) { namespaces(query, callback) {
var url = Api.buildUrl(Api.namespacesPath); const url = Api.buildUrl(Api.namespacesPath);
return $.ajax({ return $.ajax({
url: url, url,
data: { data: {
search: query, search: query,
per_page: 20 per_page: 20,
}, },
dataType: 'json' dataType: 'json',
}).done(function(namespaces) { }).done(namespaces => callback(namespaces));
return callback(namespaces);
});
}, },
// Return projects list. Filtered by query // Return projects list. Filtered by query
projects: function(query, options, callback) { projects(query, options, callback) {
var url = Api.buildUrl(Api.projectsPath); const url = Api.buildUrl(Api.projectsPath);
return $.ajax({ return $.ajax({
url: url, url,
data: $.extend({ data: Object.assign({
search: query, search: query,
per_page: 20, per_page: 20,
membership: true membership: true,
}, options), }, options),
dataType: 'json' dataType: 'json',
}).done(function(projects) { })
return callback(projects); .done(projects => callback(projects));
});
}, },
newLabel: function(namespace_path, project_path, data, callback) {
var url = Api.buildUrl(Api.labelsPath) newLabel(namespacePath, projectPath, data, callback) {
.replace(':namespace_path', namespace_path) const url = Api.buildUrl(Api.labelsPath)
.replace(':project_path', project_path); .replace(':namespace_path', namespacePath)
.replace(':project_path', projectPath);
return $.ajax({ return $.ajax({
url: url, url,
type: 'POST', type: 'POST',
data: { 'label': data }, data: { label: data },
dataType: 'json' dataType: 'json',
}).done(function(label) { })
return callback(label); .done(label => callback(label))
}).error(function(message) { .error(message => callback(message.responseJSON));
return callback(message.responseJSON);
});
}, },
// Return group projects list. Filtered by query // Return group projects list. Filtered by query
groupProjects: function(group_id, query, callback) { groupProjects(groupId, query, callback) {
var url = Api.buildUrl(Api.groupProjectsPath) const url = Api.buildUrl(Api.groupProjectsPath)
.replace(':id', group_id); .replace(':id', groupId);
return $.ajax({ return $.ajax({
url: url, url,
data: { data: {
search: query, search: query,
per_page: 20 per_page: 20,
}, },
dataType: 'json' dataType: 'json',
}).done(function(projects) { })
return callback(projects); .done(projects => callback(projects));
});
}, },
// Return text for a specific license // Return text for a specific license
licenseText: function(key, data, callback) { licenseText(key, data, callback) {
var url = Api.buildUrl(Api.licensePath) const url = Api.buildUrl(Api.licensePath)
.replace(':key', key); .replace(':key', key);
return $.ajax({ return $.ajax({
url: url, url,
data: data data,
}).done(function(license) { })
return callback(license); .done(license => callback(license));
});
}, },
gitignoreText: function(key, callback) {
var url = Api.buildUrl(Api.gitignorePath) gitignoreText(key, callback) {
const url = Api.buildUrl(Api.gitignorePath)
.replace(':key', key); .replace(':key', key);
return $.get(url, function(gitignore) { return $.get(url, gitignore => callback(gitignore));
return callback(gitignore);
});
}, },
gitlabCiYml: function(key, callback) {
var url = Api.buildUrl(Api.gitlabCiYmlPath) gitlabCiYml(key, callback) {
const url = Api.buildUrl(Api.gitlabCiYmlPath)
.replace(':key', key); .replace(':key', key);
return $.get(url, function(file) { return $.get(url, file => callback(file));
return callback(file);
});
}, },
dockerfileYml: function(key, callback) {
var url = Api.buildUrl(Api.dockerfilePath).replace(':key', key); dockerfileYml(key, callback) {
const url = Api.buildUrl(Api.dockerfilePath).replace(':key', key);
$.get(url, callback); $.get(url, callback);
}, },
issueTemplate: function(namespacePath, projectPath, key, type, callback) {
var url = Api.buildUrl(Api.issuableTemplatePath) issueTemplate(namespacePath, projectPath, key, type, callback) {
const url = Api.buildUrl(Api.issuableTemplatePath)
.replace(':key', key) .replace(':key', key)
.replace(':type', type) .replace(':type', type)
.replace(':project_path', projectPath) .replace(':project_path', projectPath)
.replace(':namespace_path', namespacePath); .replace(':namespace_path', namespacePath);
$.ajax({ $.ajax({
url: url, url,
dataType: 'json' dataType: 'json',
}).done(function(file) { })
callback(null, file); .done(file => callback(null, file))
}).error(callback); .error(callback);
}, },
buildUrl: function(url) {
if (gon.relative_url_root != null) { users(query, options) {
url = gon.relative_url_root + url; const url = Api.buildUrl(this.usersPath);
} return Api.wrapAjaxCall({
return url.replace(':version', gon.api_version); url,
data: Object.assign({
search: query,
per_page: 20,
}, options),
dataType: 'json',
});
}, },
ldap_groups: function(query, provider, callback) {
var url; approverUsers(search, options, callback = $.noop) {
url = Api.buildUrl(Api.ldapGroupsPath); const url = Api.buildUrl('/autocomplete/users.json');
url = url.replace(':provider', provider);
return $.ajax({ return $.ajax({
url: url, url,
data: { data: $.extend({
search,
per_page: 20,
}, options),
dataType: 'json',
}).done(callback);
},
ldap_groups(query, provider, callback) {
const url = Api.buildUrl(this.ldapGroupsPath).replace(':provider', provider);
return Api.wrapAjaxCall({
url,
data: Object.assign({
private_token: gon.api_token, private_token: gon.api_token,
search: query, search: query,
per_page: 20, per_page: 20,
active: true active: true,
}),
dataType: 'json',
})
.done(groups => callback(groups));
}, },
dataType: 'json'
}).done(function(groups) { buildUrl(url) {
return callback(groups); let urlRoot = '';
}); if (gon.relative_url_root != null) {
urlRoot = gon.relative_url_root;
} }
return urlRoot + url.replace(':version', gon.api_version);
},
wrapAjaxCall(options) {
return new Promise((resolve, reject) => {
// jQuery 2 is not Promises/A+ compatible (missing catch)
$.ajax(options) // eslint-disable-line promise/catch-or-return
.then(data => resolve(data),
(jqXHR, textStatus, errorThrown) => {
const error = new Error(`${options.url}: ${errorThrown}`);
error.textStatus = textStatus;
reject(error);
},
);
});
},
}; };
window.Api = Api; export default Api;
/* global Api */ import Api from './api';
export default class ApproversSelect { export default class ApproversSelect {
constructor() { constructor() {
...@@ -46,7 +46,7 @@ export default class ApproversSelect { ...@@ -46,7 +46,7 @@ export default class ApproversSelect {
skip_users: ApproversSelect.getApprovers(this.fieldNames[0], '.js-approver'), skip_users: ApproversSelect.getApprovers(this.fieldNames[0], '.js-approver'),
project_id: $('#project_id').val(), project_id: $('#project_id').val(),
}; };
return Api.users(term, options); return Api.approverUsers(term, options);
} }
handleSelectChange(e) { handleSelectChange(e) {
......
/* global Api */
export default class FileTemplateSelector { export default class FileTemplateSelector {
constructor(mediator) { constructor(mediator) {
this.mediator = mediator; this.mediator = mediator;
...@@ -65,4 +63,3 @@ export default class FileTemplateSelector { ...@@ -65,4 +63,3 @@ export default class FileTemplateSelector {
this.reportSelection(opts); this.reportSelection(opts);
} }
} }
/* global Api */ import Api from '../../api';
import FileTemplateSelector from '../file_template_selector'; import FileTemplateSelector from '../file_template_selector';
......
/* global Api */ import Api from '../../api';
import FileTemplateSelector from '../file_template_selector'; import FileTemplateSelector from '../file_template_selector';
......
/* global Api */ import Api from '../../api';
import FileTemplateSelector from '../file_template_selector'; import FileTemplateSelector from '../file_template_selector';
......
/* global Api */ import Api from '../../api';
import FileTemplateSelector from '../file_template_selector'; import FileTemplateSelector from '../file_template_selector';
......
...@@ -50,9 +50,9 @@ export default class BlobViewer { ...@@ -50,9 +50,9 @@ export default class BlobViewer {
if (this.copySourceBtn) { if (this.copySourceBtn) {
this.copySourceBtn.addEventListener('click', () => { this.copySourceBtn.addEventListener('click', () => {
if (this.copySourceBtn.classList.contains('disabled')) return; if (this.copySourceBtn.classList.contains('disabled')) return this.copySourceBtn.blur();
this.switchToViewer('simple'); return this.switchToViewer('simple');
}); });
} }
} }
......
...@@ -72,8 +72,6 @@ import extraMilestones from '../mixins/extra_milestones'; ...@@ -72,8 +72,6 @@ import extraMilestones from '../mixins/extra_milestones';
const milestoneDropdown = this.$refs.milestoneDropdown; const milestoneDropdown = this.$refs.milestoneDropdown;
const rect = e.target.getBoundingClientRect(); const rect = e.target.getBoundingClientRect();
milestoneDropdown.style.left = `${rect.left}px`;
milestoneDropdown.style.top = `${rect.bottom}px`;
milestoneDropdown.style.width = `${rect.width}px`; milestoneDropdown.style.width = `${rect.width}px`;
}); });
} }
......
import Vue from 'vue'; import Vue from 'vue';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
const Store = gl.issueBoards.BoardsStore; const Store = gl.issueBoards.BoardsStore;
...@@ -38,6 +39,9 @@ gl.issueBoards.IssueCardInner = Vue.extend({ ...@@ -38,6 +39,9 @@ gl.issueBoards.IssueCardInner = Vue.extend({
maxCounter: 99, maxCounter: 99,
}; };
}, },
components: {
userAvatarLink,
},
computed: { computed: {
numberOverLimit() { numberOverLimit() {
return this.issue.assignees.length - this.limitBeforeCounter; return this.issue.assignees.length - this.limitBeforeCounter;
...@@ -146,23 +150,16 @@ gl.issueBoards.IssueCardInner = Vue.extend({ ...@@ -146,23 +150,16 @@ gl.issueBoards.IssueCardInner = Vue.extend({
</span> </span>
</h4> </h4>
<div class="card-assignee"> <div class="card-assignee">
<a <user-avatar-link
class="has-tooltip js-no-trigger"
:href="assigneeUrl(assignee)"
:title="assigneeUrlTitle(assignee)"
v-for="(assignee, index) in issue.assignees" v-for="(assignee, index) in issue.assignees"
v-if="shouldRenderAssignee(index)" v-if="shouldRenderAssignee(index)"
data-container="body" class="js-no-trigger"
data-placement="bottom" :link-href="assigneeUrl(assignee)"
> :img-alt="avatarUrlTitle(assignee)"
<img :img-src="assignee.avatar"
class="avatar avatar-inline s20" :tooltip-text="assigneeUrlTitle(assignee)"
:src="assignee.avatar" tooltip-placement="bottom"
width="20"
height="20"
:alt="avatarUrlTitle(assignee)"
/> />
</a>
<span <span
class="avatar-counter has-tooltip" class="avatar-counter has-tooltip"
:title="assigneeCounterTooltip" :title="assigneeCounterTooltip"
......
...@@ -3,7 +3,6 @@ ...@@ -3,7 +3,6 @@
import Vue from 'vue'; import Vue from 'vue';
import queryData from '../../utils/query_data'; import queryData from '../../utils/query_data';
import loadingIcon from '../../../vue_shared/components/loading_icon.vue'; import loadingIcon from '../../../vue_shared/components/loading_icon.vue';
import './header'; import './header';
import './list'; import './list';
import './footer'; import './footer';
......
...@@ -18,12 +18,12 @@ const gfmRules = { ...@@ -18,12 +18,12 @@ const gfmRules = {
}, },
}, },
TaskListFilter: { TaskListFilter: {
'input[type=checkbox].task-list-item-checkbox'(el, text) { 'input[type=checkbox].task-list-item-checkbox'(el) {
return `[${el.checked ? 'x' : ' '}]`; return `[${el.checked ? 'x' : ' '}]`;
}, },
}, },
ReferenceFilter: { ReferenceFilter: {
'.tooltip'(el, text) { '.tooltip'(el) {
return ''; return '';
}, },
'a.gfm:not([data-link=true])'(el, text) { 'a.gfm:not([data-link=true])'(el, text) {
...@@ -39,15 +39,15 @@ const gfmRules = { ...@@ -39,15 +39,15 @@ const gfmRules = {
}, },
}, },
TableOfContentsFilter: { TableOfContentsFilter: {
'ul.section-nav'(el, text) { 'ul.section-nav'(el) {
return '[[_TOC_]]'; return '[[_TOC_]]';
}, },
}, },
EmojiFilter: { EmojiFilter: {
'img.emoji'(el, text) { 'img.emoji'(el) {
return el.getAttribute('alt'); return el.getAttribute('alt');
}, },
'gl-emoji'(el, text) { 'gl-emoji'(el) {
return `:${el.getAttribute('data-name')}:`; return `:${el.getAttribute('data-name')}:`;
}, },
}, },
...@@ -57,13 +57,13 @@ const gfmRules = { ...@@ -57,13 +57,13 @@ const gfmRules = {
}, },
}, },
VideoLinkFilter: { VideoLinkFilter: {
'.video-container'(el, text) { '.video-container'(el) {
const videoEl = el.querySelector('video'); const videoEl = el.querySelector('video');
if (!videoEl) return false; if (!videoEl) return false;
return CopyAsGFM.nodeToGFM(videoEl); return CopyAsGFM.nodeToGFM(videoEl);
}, },
'video'(el, text) { 'video'(el) {
return `![${el.dataset.title}](${el.getAttribute('src')})`; return `![${el.dataset.title}](${el.getAttribute('src')})`;
}, },
}, },
...@@ -74,19 +74,19 @@ const gfmRules = { ...@@ -74,19 +74,19 @@ const gfmRules = {
'code.code.math[data-math-style=inline]'(el, text) { 'code.code.math[data-math-style=inline]'(el, text) {
return `$\`${text}\`$`; return `$\`${text}\`$`;
}, },
'span.katex-display span.katex-mathml'(el, text) { 'span.katex-display span.katex-mathml'(el) {
const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
if (!mathAnnotation) return false; if (!mathAnnotation) return false;
return `\`\`\`math\n${CopyAsGFM.nodeToGFM(mathAnnotation)}\n\`\`\``; return `\`\`\`math\n${CopyAsGFM.nodeToGFM(mathAnnotation)}\n\`\`\``;
}, },
'span.katex-mathml'(el, text) { 'span.katex-mathml'(el) {
const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
if (!mathAnnotation) return false; if (!mathAnnotation) return false;
return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`; return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`;
}, },
'span.katex-html'(el, text) { 'span.katex-html'(el) {
// We don't want to include the content of this element in the copied text. // We don't want to include the content of this element in the copied text.
return ''; return '';
}, },
...@@ -95,7 +95,7 @@ const gfmRules = { ...@@ -95,7 +95,7 @@ const gfmRules = {
}, },
}, },
SanitizationFilter: { SanitizationFilter: {
'a[name]:not([href]):empty'(el, text) { 'a[name]:not([href]):empty'(el) {
return el.outerHTML; return el.outerHTML;
}, },
'dl'(el, text) { 'dl'(el, text) {
...@@ -143,7 +143,7 @@ const gfmRules = { ...@@ -143,7 +143,7 @@ const gfmRules = {
}, },
}, },
MarkdownFilter: { MarkdownFilter: {
'br'(el, text) { 'br'(el) {
// Two spaces at the end of a line are turned into a BR // Two spaces at the end of a line are turned into a BR
return ' '; return ' ';
}, },
...@@ -162,7 +162,7 @@ const gfmRules = { ...@@ -162,7 +162,7 @@ const gfmRules = {
'blockquote'(el, text) { 'blockquote'(el, text) {
return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n'); return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n');
}, },
'img'(el, text) { 'img'(el) {
return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`; return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`;
}, },
'a.anchor'(el, text) { 'a.anchor'(el, text) {
...@@ -222,10 +222,10 @@ const gfmRules = { ...@@ -222,10 +222,10 @@ const gfmRules = {
'sup'(el, text) { 'sup'(el, text) {
return `^${text}`; return `^${text}`;
}, },
'hr'(el, text) { 'hr'(el) {
return '-----'; return '-----';
}, },
'table'(el, text) { 'table'(el) {
const theadEl = el.querySelector('thead'); const theadEl = el.querySelector('thead');
const tbodyEl = el.querySelector('tbody'); const tbodyEl = el.querySelector('tbody');
if (!theadEl || !tbodyEl) return false; if (!theadEl || !tbodyEl) return false;
...@@ -233,11 +233,11 @@ const gfmRules = { ...@@ -233,11 +233,11 @@ const gfmRules = {
const theadText = CopyAsGFM.nodeToGFM(theadEl); const theadText = CopyAsGFM.nodeToGFM(theadEl);
const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl); const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl);
return theadText + tbodyText; return [theadText, tbodyText].join('\n');
}, },
'thead'(el, text) { 'thead'(el, text) {
const cells = _.map(el.querySelectorAll('th'), (cell) => { const cells = _.map(el.querySelectorAll('th'), (cell) => {
let chars = CopyAsGFM.nodeToGFM(cell).trim().length + 2; let chars = CopyAsGFM.nodeToGFM(cell).length + 2;
let before = ''; let before = '';
let after = ''; let after = '';
...@@ -262,10 +262,15 @@ const gfmRules = { ...@@ -262,10 +262,15 @@ const gfmRules = {
return before + middle + after; return before + middle + after;
}); });
return `${text}|${cells.join('|')}|`; const separatorRow = `|${cells.join('|')}|`;
return [text, separatorRow].join('\n');
}, },
'tr'(el, text) { 'tr'(el) {
const cells = _.map(el.querySelectorAll('td, th'), cell => CopyAsGFM.nodeToGFM(cell).trim()); const cellEls = el.querySelectorAll('td, th');
if (cellEls.length === 0) return false;
const cells = _.map(cellEls, cell => CopyAsGFM.nodeToGFM(cell));
return `| ${cells.join(' | ')} |`; return `| ${cells.join(' | ')} |`;
}, },
}, },
...@@ -273,12 +278,12 @@ const gfmRules = { ...@@ -273,12 +278,12 @@ const gfmRules = {
class CopyAsGFM { class CopyAsGFM {
constructor() { constructor() {
$(document).on('copy', '.md, .wiki', (e) => { this.copyAsGFM(e, CopyAsGFM.transformGFMSelection); }); $(document).on('copy', '.md, .wiki', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformGFMSelection); });
$(document).on('copy', 'pre.code.highlight, .diff-content .line_content', (e) => { this.copyAsGFM(e, CopyAsGFM.transformCodeSelection); }); $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformCodeSelection); });
$(document).on('paste', '.js-gfm-input', this.pasteGFM.bind(this)); $(document).on('paste', '.js-gfm-input', CopyAsGFM.pasteGFM);
} }
copyAsGFM(e, transformer) { static copyAsGFM(e, transformer) {
const clipboardData = e.originalEvent.clipboardData; const clipboardData = e.originalEvent.clipboardData;
if (!clipboardData) return; if (!clipboardData) return;
...@@ -292,27 +297,60 @@ class CopyAsGFM { ...@@ -292,27 +297,60 @@ class CopyAsGFM {
e.stopPropagation(); e.stopPropagation();
clipboardData.setData('text/plain', el.textContent); clipboardData.setData('text/plain', el.textContent);
clipboardData.setData('text/x-gfm', CopyAsGFM.nodeToGFM(el)); clipboardData.setData('text/x-gfm', this.nodeToGFM(el));
} }
pasteGFM(e) { static pasteGFM(e) {
const clipboardData = e.originalEvent.clipboardData; const clipboardData = e.originalEvent.clipboardData;
if (!clipboardData) return; if (!clipboardData) return;
const text = clipboardData.getData('text/plain');
const gfm = clipboardData.getData('text/x-gfm'); const gfm = clipboardData.getData('text/x-gfm');
if (!gfm) return; if (!gfm) return;
e.preventDefault(); e.preventDefault();
window.gl.utils.insertText(e.target, gfm); window.gl.utils.insertText(e.target, (textBefore, textAfter) => {
// If the text before the cursor contains an odd number of backticks,
// we are either inside an inline code span that starts with 1 backtick
// or a code block that starts with 3 backticks.
// This logic still holds when there are one or more _closed_ code spans
// or blocks that will have 2 or 6 backticks.
// This will break down when the actual code block contains an uneven
// number of backticks, but this is a rare edge case.
const backtickMatch = textBefore.match(/`/g);
const insideCodeBlock = backtickMatch && (backtickMatch.length % 2) === 1;
if (insideCodeBlock) {
return text;
} }
static transformGFMSelection(documentFragment) { return gfm;
// If the documentFragment contains more than just Markdown, don't copy as GFM. });
if (documentFragment.querySelector('.md, .wiki')) return null; }
static transformGFMSelection(documentFragment) {
const gfmEls = documentFragment.querySelectorAll('.md, .wiki');
switch (gfmEls.length) {
case 0: {
return documentFragment; return documentFragment;
} }
case 1: {
return gfmEls[0];
}
default: {
const allGfmEl = document.createElement('div');
for (let i = 0; i < gfmEls.length; i += 1) {
const lineEl = gfmEls[i];
allGfmEl.appendChild(lineEl);
allGfmEl.appendChild(document.createTextNode('\n\n'));
}
return allGfmEl;
}
}
}
static transformCodeSelection(documentFragment) { static transformCodeSelection(documentFragment) {
const lineEls = documentFragment.querySelectorAll('.line'); const lineEls = documentFragment.querySelectorAll('.line');
...@@ -343,7 +381,7 @@ class CopyAsGFM { ...@@ -343,7 +381,7 @@ class CopyAsGFM {
return codeEl; return codeEl;
} }
static nodeToGFM(node) { static nodeToGFM(node, respectWhitespaceParam = false) {
if (node.nodeType === Node.COMMENT_NODE) { if (node.nodeType === Node.COMMENT_NODE) {
return ''; return '';
} }
...@@ -352,7 +390,9 @@ class CopyAsGFM { ...@@ -352,7 +390,9 @@ class CopyAsGFM {
return node.textContent; return node.textContent;
} }
const text = this.innerGFM(node); const respectWhitespace = respectWhitespaceParam || (node.nodeName === 'PRE' || node.nodeName === 'CODE');
const text = this.innerGFM(node, respectWhitespace);
if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
return text; return text;
...@@ -366,7 +406,17 @@ class CopyAsGFM { ...@@ -366,7 +406,17 @@ class CopyAsGFM {
if (!window.gl.utils.nodeMatchesSelector(node, selector)) continue; if (!window.gl.utils.nodeMatchesSelector(node, selector)) continue;
const result = func(node, text); let result;
if (func.length === 2) {
// if `func` takes 2 arguments, it depends on text.
// if there is no text, we don't need to generate GFM for this node.
if (text.length === 0) continue;
result = func(node, text);
} else {
result = func(node);
}
if (result === false) continue; if (result === false) continue;
return result; return result;
...@@ -376,7 +426,7 @@ class CopyAsGFM { ...@@ -376,7 +426,7 @@ class CopyAsGFM {
return text; return text;
} }
static innerGFM(parentNode) { static innerGFM(parentNode, respectWhitespace = false) {
const nodes = parentNode.childNodes; const nodes = parentNode.childNodes;
const clonedParentNode = parentNode.cloneNode(true); const clonedParentNode = parentNode.cloneNode(true);
...@@ -386,13 +436,19 @@ class CopyAsGFM { ...@@ -386,13 +436,19 @@ class CopyAsGFM {
const node = nodes[i]; const node = nodes[i];
const clonedNode = clonedNodes[i]; const clonedNode = clonedNodes[i];
const text = this.nodeToGFM(node); const text = this.nodeToGFM(node, respectWhitespace);
// `clonedNode.replaceWith(text)` is not yet widely supported // `clonedNode.replaceWith(text)` is not yet widely supported
clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode); clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode);
} }
return clonedParentNode.innerText || clonedParentNode.textContent; let nodeText = clonedParentNode.innerText || clonedParentNode.textContent;
if (!respectWhitespace) {
nodeText = nodeText.trim();
}
return nodeText;
} }
} }
......
/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, comma-dangle, prefer-template, quotes, no-param-reassign, wrap-iife, max-len */ /* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, comma-dangle, prefer-template, quotes, no-param-reassign, wrap-iife, max-len */
/* global Api */ import Api from './api';
class CreateLabelDropdown { class CreateLabelDropdown {
constructor ($el, namespacePath, projectPath) { constructor ($el, namespacePath, projectPath) {
......
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import Vue from 'vue'; import Vue from 'vue';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const global = window.gl || (window.gl = {}); const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {}; global.cycleAnalytics = global.cycleAnalytics || {};
...@@ -10,6 +11,9 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({ ...@@ -10,6 +11,9 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({
items: Array, items: Array,
stage: Object, stage: Object,
}, },
components: {
userAvatarImage,
},
template: ` template: `
<div> <div>
<div class="events-description"> <div class="events-description">
...@@ -19,7 +23,8 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({ ...@@ -19,7 +23,8 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({
<ul class="stage-event-list"> <ul class="stage-event-list">
<li v-for="mergeRequest in items" class="stage-event-item"> <li v-for="mergeRequest in items" class="stage-event-item">
<div class="item-details"> <div class="item-details">
<img class="avatar" :src="mergeRequest.author.avatarUrl"> <!-- FIXME: Pass an alt attribute here for accessibility -->
<user-avatar-image :img-src="mergeRequest.author.avatarUrl"/>
<h5 class="item-title merge-merquest-title"> <h5 class="item-title merge-merquest-title">
<a :href="mergeRequest.url"> <a :href="mergeRequest.url">
{{ mergeRequest.title }} {{ mergeRequest.title }}
...@@ -28,11 +33,11 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({ ...@@ -28,11 +33,11 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({
<a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a>
&middot; &middot;
<span> <span>
{{ __('OpenedNDaysAgo|Opened') }} {{ s__('OpenedNDaysAgo|Opened') }}
<a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a>
</span> </span>
<span> <span>
{{ __('ByAuthor|by') }} {{ s__('ByAuthor|by') }}
<a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a>
</span> </span>
</div> </div>
......
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import Vue from 'vue'; import Vue from 'vue';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const global = window.gl || (window.gl = {}); const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {}; global.cycleAnalytics = global.cycleAnalytics || {};
...@@ -10,6 +10,9 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({ ...@@ -10,6 +10,9 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({
items: Array, items: Array,
stage: Object, stage: Object,
}, },
components: {
userAvatarImage,
},
template: ` template: `
<div> <div>
<div class="events-description"> <div class="events-description">
...@@ -19,7 +22,8 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({ ...@@ -19,7 +22,8 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({
<ul class="stage-event-list"> <ul class="stage-event-list">
<li v-for="issue in items" class="stage-event-item"> <li v-for="issue in items" class="stage-event-item">
<div class="item-details"> <div class="item-details">
<img class="avatar" :src="issue.author.avatarUrl"> <!-- FIXME: Pass an alt attribute here for accessibility -->
<user-avatar-image :img-src="issue.author.avatarUrl"/>
<h5 class="item-title issue-title"> <h5 class="item-title issue-title">
<a class="issue-title" :href="issue.url"> <a class="issue-title" :href="issue.url">
{{ issue.title }} {{ issue.title }}
...@@ -28,11 +32,11 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({ ...@@ -28,11 +32,11 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({
<a :href="issue.url" class="issue-link">#{{ issue.iid }}</a> <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a>
&middot; &middot;
<span> <span>
{{ __('OpenedNDaysAgo|Opened') }} {{ s__('OpenedNDaysAgo|Opened') }}
<a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a>
</span> </span>
<span> <span>
{{ __('ByAuthor|by') }} {{ s__('ByAuthor|by') }}
<a :href="issue.author.webUrl" class="issue-author-link"> <a :href="issue.author.webUrl" class="issue-author-link">
{{ issue.author.name }} {{ issue.author.name }}
</a> </a>
......
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import Vue from 'vue'; import Vue from 'vue';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
import iconCommit from '../svg/icon_commit.svg'; import iconCommit from '../svg/icon_commit.svg';
const global = window.gl || (window.gl = {}); const global = window.gl || (window.gl = {});
...@@ -10,11 +11,12 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({ ...@@ -10,11 +11,12 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({
items: Array, items: Array,
stage: Object, stage: Object,
}, },
components: {
userAvatarImage,
},
data() { data() {
return { iconCommit }; return { iconCommit };
}, },
template: ` template: `
<div> <div>
<div class="events-description"> <div class="events-description">
...@@ -24,17 +26,18 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({ ...@@ -24,17 +26,18 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({
<ul class="stage-event-list"> <ul class="stage-event-list">
<li v-for="commit in items" class="stage-event-item"> <li v-for="commit in items" class="stage-event-item">
<div class="item-details item-conmmit-component"> <div class="item-details item-conmmit-component">
<img class="avatar" :src="commit.author.avatarUrl"> <!-- FIXME: Pass an alt attribute here for accessibility -->
<user-avatar-image :img-src="commit.author.avatarUrl"/>
<h5 class="item-title commit-title"> <h5 class="item-title commit-title">
<a :href="commit.commitUrl"> <a :href="commit.commitUrl">
{{ commit.title }} {{ commit.title }}
</a> </a>
</h5> </h5>
<span> <span>
{{ __('FirstPushedBy|First') }} {{ s__('FirstPushedBy|First') }}
<span class="commit-icon">${iconCommit}</span> <span class="commit-icon">${iconCommit}</span>
<a :href="commit.commitUrl" class="commit-hash-link commit-sha">{{ commit.shortSha }}</a> <a :href="commit.commitUrl" class="commit-hash-link commit-sha">{{ commit.shortSha }}</a>
{{ __('FirstPushedBy|pushed by') }} {{ s__('FirstPushedBy|pushed by') }}
<a :href="commit.author.webUrl" class="commit-author-link"> <a :href="commit.author.webUrl" class="commit-author-link">
{{ commit.author.name }} {{ commit.author.name }}
</a> </a>
......
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import Vue from 'vue'; import Vue from 'vue';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const global = window.gl || (window.gl = {}); const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {}; global.cycleAnalytics = global.cycleAnalytics || {};
...@@ -10,6 +10,9 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({ ...@@ -10,6 +10,9 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({
items: Array, items: Array,
stage: Object, stage: Object,
}, },
components: {
userAvatarImage,
},
template: ` template: `
<div> <div>
<div class="events-description"> <div class="events-description">
...@@ -19,7 +22,8 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({ ...@@ -19,7 +22,8 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({
<ul class="stage-event-list"> <ul class="stage-event-list">
<li v-for="issue in items" class="stage-event-item"> <li v-for="issue in items" class="stage-event-item">
<div class="item-details"> <div class="item-details">
<img class="avatar" :src="issue.author.avatarUrl"> <!-- FIXME: Pass an alt attribute here for accessibility -->
<user-avatar-image :img-src="issue.author.avatarUrl"/>
<h5 class="item-title issue-title"> <h5 class="item-title issue-title">
<a class="issue-title" :href="issue.url"> <a class="issue-title" :href="issue.url">
{{ issue.title }} {{ issue.title }}
...@@ -28,11 +32,11 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({ ...@@ -28,11 +32,11 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({
<a :href="issue.url" class="issue-link">#{{ issue.iid }}</a> <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a>
&middot; &middot;
<span> <span>
{{ __('OpenedNDaysAgo|Opened') }} {{ s__('OpenedNDaysAgo|Opened') }}
<a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a>
</span> </span>
<span> <span>
{{ __('ByAuthor|by') }} {{ s__('ByAuthor|by') }}
<a :href="issue.author.webUrl" class="issue-author-link"> <a :href="issue.author.webUrl" class="issue-author-link">
{{ issue.author.name }} {{ issue.author.name }}
</a> </a>
......
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import Vue from 'vue'; import Vue from 'vue';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const global = window.gl || (window.gl = {}); const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {}; global.cycleAnalytics = global.cycleAnalytics || {};
...@@ -10,6 +10,9 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({ ...@@ -10,6 +10,9 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({
items: Array, items: Array,
stage: Object, stage: Object,
}, },
components: {
userAvatarImage,
},
template: ` template: `
<div> <div>
<div class="events-description"> <div class="events-description">
...@@ -19,7 +22,8 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({ ...@@ -19,7 +22,8 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({
<ul class="stage-event-list"> <ul class="stage-event-list">
<li v-for="mergeRequest in items" class="stage-event-item"> <li v-for="mergeRequest in items" class="stage-event-item">
<div class="item-details"> <div class="item-details">
<img class="avatar" :src="mergeRequest.author.avatarUrl"> <!-- FIXME: Pass an alt attribute here for accessibility -->
<user-avatar-image :img-src="mergeRequest.author.avatarUrl"/>
<h5 class="item-title merge-merquest-title"> <h5 class="item-title merge-merquest-title">
<a :href="mergeRequest.url"> <a :href="mergeRequest.url">
{{ mergeRequest.title }} {{ mergeRequest.title }}
...@@ -28,11 +32,11 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({ ...@@ -28,11 +32,11 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({
<a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a>
&middot; &middot;
<span> <span>
{{ __('OpenedNDaysAgo|Opened') }} {{ s__('OpenedNDaysAgo|Opened') }}
<a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a>
</span> </span>
<span> <span>
{{ __('ByAuthor|by') }} {{ s__('ByAuthor|by') }}
<a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a>
</span> </span>
<template v-if="mergeRequest.state === 'closed'"> <template v-if="mergeRequest.state === 'closed'">
......
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import Vue from 'vue'; import Vue from 'vue';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
import iconBranch from '../svg/icon_branch.svg'; import iconBranch from '../svg/icon_branch.svg';
const global = window.gl || (window.gl = {}); const global = window.gl || (window.gl = {});
...@@ -13,6 +14,9 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({ ...@@ -13,6 +14,9 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({
data() { data() {
return { iconBranch }; return { iconBranch };
}, },
components: {
userAvatarImage,
},
template: ` template: `
<div> <div>
<div class="events-description"> <div class="events-description">
...@@ -22,7 +26,8 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({ ...@@ -22,7 +26,8 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({
<ul class="stage-event-list"> <ul class="stage-event-list">
<li v-for="build in items" class="stage-event-item item-build-component"> <li v-for="build in items" class="stage-event-item item-build-component">
<div class="item-details"> <div class="item-details">
<img class="avatar" :src="build.author.avatarUrl"> <!-- FIXME: Pass an alt attribute here for accessibility -->
<user-avatar-image :img-src="build.author.avatarUrl"/>
<h5 class="item-title"> <h5 class="item-title">
<a :href="build.url" class="pipeline-id">#{{ build.id }}</a> <a :href="build.url" class="pipeline-id">#{{ build.id }}</a>
<i class="fa fa-code-fork"></i> <i class="fa fa-code-fork"></i>
...@@ -32,7 +37,7 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({ ...@@ -32,7 +37,7 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({
</h5> </h5>
<span> <span>
<a :href="build.url" class="build-date">{{ build.date }}</a> <a :href="build.url" class="build-date">{{ build.date }}</a>
{{ __('ByAuthor|by') }} {{ s__('ByAuthor|by') }}
<a :href="build.author.webUrl" class="issue-author-link"> <a :href="build.author.webUrl" class="issue-author-link">
{{ build.author.name }} {{ build.author.name }}
</a> </a>
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
import Vue from 'vue'; import Vue from 'vue';
import collapseIcon from '../icons/collapse_icon.svg'; import collapseIcon from '../icons/collapse_icon.svg';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const DiffNoteAvatars = Vue.extend({ const DiffNoteAvatars = Vue.extend({
props: ['discussionId'], props: ['discussionId'],
...@@ -15,22 +16,24 @@ const DiffNoteAvatars = Vue.extend({ ...@@ -15,22 +16,24 @@ const DiffNoteAvatars = Vue.extend({
collapseIcon, collapseIcon,
}; };
}, },
components: {
userAvatarImage,
},
template: ` template: `
<div class="diff-comment-avatar-holders" <div class="diff-comment-avatar-holders"
v-show="notesCount !== 0"> v-show="notesCount !== 0">
<div v-if="!isVisible"> <div v-if="!isVisible">
<img v-for="note in notesSubset" <!-- FIXME: Pass an alt attribute here for accessibility -->
class="avatar diff-comment-avatar has-tooltip js-diff-comment-avatar" <user-avatar-image
width="19" v-for="note in notesSubset"
height="19" class="diff-comment-avatar js-diff-comment-avatar"
role="button" @click.native="clickedAvatar($event)"
data-container="body" :img-src="note.authorAvatar"
data-placement="top" :tooltip-text="getTooltipText(note)"
data-html="true"
:data-line-type="lineType" :data-line-type="lineType"
:title="note.authorName + ': ' + note.noteTruncated" :size="19"
:src="note.authorAvatar" data-html="true"
@click="clickedAvatar($event)" /> />
<span v-if="notesCount > shownAvatars" <span v-if="notesCount > shownAvatars"
class="diff-comments-more-count has-tooltip js-diff-comment-avatar" class="diff-comments-more-count has-tooltip js-diff-comment-avatar"
data-container="body" data-container="body"
...@@ -150,6 +153,9 @@ const DiffNoteAvatars = Vue.extend({ ...@@ -150,6 +153,9 @@ const DiffNoteAvatars = Vue.extend({
setDiscussionVisible() { setDiscussionVisible() {
this.isVisible = $(`.diffs .notes[data-discussion-id="${this.discussion.id}"]`).is(':visible'); this.isVisible = $(`.diffs .notes[data-discussion-id="${this.discussion.id}"]`).is(':visible');
}, },
getTooltipText(note) {
return `${note.authorName}: ${note.noteTruncated}`;
},
}, },
}); });
......
...@@ -42,6 +42,7 @@ import Group from './group'; ...@@ -42,6 +42,7 @@ import Group from './group';
import GroupName from './group_name'; import GroupName from './group_name';
import GroupsList from './groups_list'; import GroupsList from './groups_list';
import ProjectsList from './projects_list'; import ProjectsList from './projects_list';
import setupProjectEdit from './project_edit';
import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater'; import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater';
import Landing from './landing'; import Landing from './landing';
...@@ -52,7 +53,6 @@ import ShortcutsWiki from './shortcuts_wiki'; ...@@ -52,7 +53,6 @@ import ShortcutsWiki from './shortcuts_wiki';
import Pipelines from './pipelines'; import Pipelines from './pipelines';
import BlobViewer from './blob/viewer/index'; import BlobViewer from './blob/viewer/index';
import GeoNodes from './geo_nodes'; import GeoNodes from './geo_nodes';
import ServiceDeskRoot from './projects/settings_service_desk/service_desk_root';
import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
import UsersSelect from './users_select'; import UsersSelect from './users_select';
import RefSelectDropdown from './ref_select_dropdown'; import RefSelectDropdown from './ref_select_dropdown';
...@@ -264,20 +264,20 @@ import ApproversSelect from './approvers_select'; ...@@ -264,20 +264,20 @@ import ApproversSelect from './approvers_select';
break; break;
case 'projects:edit': case 'projects:edit':
new UsersSelect(); new UsersSelect();
const el = document.querySelector('.js-service-desk-setting-root');
if (el) {
const serviceDeskRoot = new ServiceDeskRoot(el);
serviceDeskRoot.init();
}
break; break;
case 'projects:show': case 'projects:show':
shortcut_handler = new ShortcutsNavigation(); shortcut_handler = new ShortcutsNavigation();
new NotificationsForm(); new NotificationsForm();
if ($('#tree-slider').length) { if ($('#tree-slider').length) {
new TreeView(); new TreeView();
}
if ($('.blob-viewer').length) {
new BlobViewer(); new BlobViewer();
} }
break; break;
case 'projects:edit':
setupProjectEdit();
break;
case 'projects:pipelines:builds': case 'projects:pipelines:builds':
case 'projects:pipelines:failures': case 'projects:pipelines:failures':
case 'projects:pipelines:show': case 'projects:pipelines:show':
......
/* eslint-disable */ /* eslint-disable */
import AjaxCache from '~/lib/utils/ajax_cache';
const Ajax = { const Ajax = {
_loadUrlData: function _loadUrlData(url) {
var self = this;
return new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest;
xhr.open('GET', url, true);
xhr.onreadystatechange = function () {
if(xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
var data = JSON.parse(xhr.responseText);
self.cache[url] = data;
return resolve(data);
} else {
return reject([xhr.responseText, xhr.status]);
}
}
};
xhr.send();
});
},
_loadData: function _loadData(data, config, self) { _loadData: function _loadData(data, config, self) {
if (config.loadingTemplate) { if (config.loadingTemplate) {
var dataLoadingTemplate = self.hook.list.list.querySelector('[data-loading-template]'); var dataLoadingTemplate = self.hook.list.list.querySelector('[data-loading-template]');
...@@ -31,7 +14,6 @@ const Ajax = { ...@@ -31,7 +14,6 @@ const Ajax = {
init: function init(hook) { init: function init(hook) {
var self = this; var self = this;
self.destroyed = false; self.destroyed = false;
self.cache = self.cache || {};
var config = hook.config.Ajax; var config = hook.config.Ajax;
this.hook = hook; this.hook = hook;
if (!config || !config.endpoint || !config.method) { if (!config || !config.endpoint || !config.method) {
...@@ -48,14 +30,10 @@ const Ajax = { ...@@ -48,14 +30,10 @@ const Ajax = {
this.listTemplate = dynamicList.outerHTML; this.listTemplate = dynamicList.outerHTML;
dynamicList.outerHTML = loadingTemplate.outerHTML; dynamicList.outerHTML = loadingTemplate.outerHTML;
} }
if (self.cache[config.endpoint]) {
self._loadData(self.cache[config.endpoint], config, self); AjaxCache.retrieve(config.endpoint)
} else { .then((data) => self._loadData(data, config, self))
this._loadUrlData(config.endpoint) .catch(config.onError);
.then(function(d) {
self._loadData(d, config, self);
}, config.onError).catch(config.onError);
}
}, },
destroy: function() { destroy: function() {
this.destroyed = true; this.destroyed = true;
......
/* eslint-disable */ /* eslint-disable */
import AjaxCache from '../../lib/utils/ajax_cache';
const AjaxFilter = { const AjaxFilter = {
init: function(hook) { init: function(hook) {
...@@ -58,50 +59,24 @@ const AjaxFilter = { ...@@ -58,50 +59,24 @@ const AjaxFilter = {
this.loading = true; this.loading = true;
var params = config.params || {}; var params = config.params || {};
params[config.searchKey] = searchValue; params[config.searchKey] = searchValue;
var self = this;
self.cache = self.cache || {};
var url = config.endpoint + this.buildParams(params); var url = config.endpoint + this.buildParams(params);
var urlCachedData = self.cache[url]; return AjaxCache.retrieve(url)
if (urlCachedData) { .then((data) => {
self._loadData(urlCachedData, config, self); this._loadData(data, config);
} else { })
this._loadUrlData(url) .catch(config.onError);
.then(function(data) {
self._loadData(data, config, self);
}, config.onError).catch(config.onError);
}
}, },
_loadUrlData: function _loadUrlData(url) { _loadData(data, config) {
var self = this; const list = this.hook.list;
return new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest;
xhr.open('GET', url, true);
xhr.onreadystatechange = function () {
if(xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
var data = JSON.parse(xhr.responseText);
self.cache[url] = data;
return resolve(data);
} else {
return reject([xhr.responseText, xhr.status]);
}
}
};
xhr.send();
});
},
_loadData: function _loadData(data, config, self) {
const list = self.hook.list;
if (config.loadingTemplate && list.data === undefined || if (config.loadingTemplate && list.data === undefined ||
list.data.length === 0) { list.data.length === 0) {
const dataLoadingTemplate = list.list.querySelector('[data-loading-template]'); const dataLoadingTemplate = list.list.querySelector('[data-loading-template]');
if (dataLoadingTemplate) { if (dataLoadingTemplate) {
dataLoadingTemplate.outerHTML = self.listTemplate; dataLoadingTemplate.outerHTML = this.listTemplate;
} }
} }
if (!self.destroyed) { if (!this.destroyed) {
var hookListChildren = list.list.children; var hookListChildren = list.list.children;
var onlyDynamicList = hookListChildren.length === 1 && hookListChildren[0].hasAttribute('data-dynamic'); var onlyDynamicList = hookListChildren.length === 1 && hookListChildren[0].hasAttribute('data-dynamic');
if (onlyDynamicList && data.length === 0) { if (onlyDynamicList && data.length === 0) {
...@@ -109,7 +84,7 @@ const AjaxFilter = { ...@@ -109,7 +84,7 @@ const AjaxFilter = {
} }
list.setData.call(list, data); list.setData.call(list, data);
} }
self.notLoading(); this.notLoading();
list.currentIndex = 0; list.currentIndex = 0;
}, },
......
<script> <script>
import Timeago from 'timeago.js'; import Timeago from 'timeago.js';
import _ from 'underscore'; import _ from 'underscore';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import '../../lib/utils/text_utility'; import '../../lib/utils/text_utility';
import ActionsComponent from './environment_actions.vue'; import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue'; import ExternalUrlComponent from './environment_external_url.vue';
...@@ -15,6 +16,7 @@ const timeagoInstance = new Timeago(); ...@@ -15,6 +16,7 @@ const timeagoInstance = new Timeago();
export default { export default {
components: { components: {
userAvatarLink,
'commit-component': CommitComponent, 'commit-component': CommitComponent,
'actions-component': ActionsComponent, 'actions-component': ActionsComponent,
'external-url-component': ExternalUrlComponent, 'external-url-component': ExternalUrlComponent,
...@@ -485,15 +487,13 @@ export default { ...@@ -485,15 +487,13 @@ export default {
<span v-if="!model.isFolder && deploymentHasUser"> <span v-if="!model.isFolder && deploymentHasUser">
by by
<a <user-avatar-link
:href="deploymentUser.web_url" class="js-deploy-user-container"
class="js-deploy-user-container"> :link-href="deploymentUser.web_url"
<img :img-src="deploymentUser.avatar_url"
class="avatar has-tooltip s20" :img-alt="userImageAltDescription"
:src="deploymentUser.avatar_url" :tooltip-text="deploymentUser.username"
:alt="userImageAltDescription" />
:title="deploymentUser.username" />
</a>
</span> </span>
</td> </td>
......
...@@ -49,6 +49,11 @@ export default { ...@@ -49,6 +49,11 @@ export default {
required: false, required: false,
default: () => ({}), default: () => ({}),
}, },
service: {
type: Object,
required: true,
},
}, },
methods: { methods: {
......
...@@ -13,13 +13,17 @@ export default { ...@@ -13,13 +13,17 @@ export default {
required: false, required: false,
default: true, default: true,
}, },
allowedKeys: {
type: Array,
required: true,
},
}, },
computed: { computed: {
processedItems() { processedItems() {
return this.items.map((item) => { return this.items.map((item) => {
const { tokens, searchToken } const { tokens, searchToken }
= gl.FilteredSearchTokenizer.processTokens(item); = gl.FilteredSearchTokenizer.processTokens(item, this.allowedKeys);
const resultantTokens = tokens.map(token => ({ const resultantTokens = tokens.map(token => ({
prefix: `${token.key}:`, prefix: `${token.key}:`,
......
...@@ -2,14 +2,18 @@ import Filter from '~/droplab/plugins/filter'; ...@@ -2,14 +2,18 @@ import Filter from '~/droplab/plugins/filter';
import './filtered_search_dropdown'; import './filtered_search_dropdown';
class DropdownHint extends gl.FilteredSearchDropdown { class DropdownHint extends gl.FilteredSearchDropdown {
constructor(droplab, dropdown, input, filter) { constructor(droplab, dropdown, input, tokenKeys, filter) {
super(droplab, dropdown, input, filter); super(droplab, dropdown, input, filter);
this.config = { this.config = {
Filter: { Filter: {
template: 'hint', template: 'hint',
filterFunction: gl.DropdownUtils.filterHint.bind(null, input), filterFunction: gl.DropdownUtils.filterHint.bind(null, {
input,
allowedKeys: tokenKeys.getKeys(),
}),
}, },
}; };
this.tokenKeys = tokenKeys;
} }
itemClicked(e) { itemClicked(e) {
...@@ -52,20 +56,13 @@ class DropdownHint extends gl.FilteredSearchDropdown { ...@@ -52,20 +56,13 @@ class DropdownHint extends gl.FilteredSearchDropdown {
} }
renderContent() { renderContent() {
const dropdownData = []; const dropdownData = this.tokenKeys.get()
.map(tokenKey => ({
[].forEach.call(this.input.closest('.filtered-search-box-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => { icon: `fa-${tokenKey.icon}`,
const { icon, hint, tag, type } = dropdownMenu.dataset; hint: tokenKey.key,
if (icon && hint && tag) { tag: `<${tokenKey.symbol}${tokenKey.key}>`,
dropdownData.push( type: tokenKey.type,
Object.assign({ }));
icon: `fa-${icon}`,
hint,
tag: `<${tag}>`,
}, type && { type }),
);
}
});
this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config); this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config);
this.droplab.setData(this.hookId, dropdownData); this.droplab.setData(this.hookId, dropdownData);
......
...@@ -5,7 +5,7 @@ import Filter from '~/droplab/plugins/filter'; ...@@ -5,7 +5,7 @@ import Filter from '~/droplab/plugins/filter';
import './filtered_search_dropdown'; import './filtered_search_dropdown';
class DropdownNonUser extends gl.FilteredSearchDropdown { class DropdownNonUser extends gl.FilteredSearchDropdown {
constructor(droplab, dropdown, input, filter, endpoint, symbol) { constructor(droplab, dropdown, input, tokenKeys, filter, endpoint, symbol) {
super(droplab, dropdown, input, filter); super(droplab, dropdown, input, filter);
this.symbol = symbol; this.symbol = symbol;
this.config = { this.config = {
......
...@@ -4,7 +4,7 @@ import AjaxFilter from '~/droplab/plugins/ajax_filter'; ...@@ -4,7 +4,7 @@ import AjaxFilter from '~/droplab/plugins/ajax_filter';
import './filtered_search_dropdown'; import './filtered_search_dropdown';
class DropdownUser extends gl.FilteredSearchDropdown { class DropdownUser extends gl.FilteredSearchDropdown {
constructor(droplab, dropdown, input, filter) { constructor(droplab, dropdown, input, tokenKeys, filter) {
super(droplab, dropdown, input, filter); super(droplab, dropdown, input, filter);
this.config = { this.config = {
AjaxFilter: { AjaxFilter: {
...@@ -25,6 +25,7 @@ class DropdownUser extends gl.FilteredSearchDropdown { ...@@ -25,6 +25,7 @@ class DropdownUser extends gl.FilteredSearchDropdown {
}, },
}, },
}; };
this.tokenKeys = tokenKeys;
} }
itemClicked(e) { itemClicked(e) {
...@@ -43,7 +44,7 @@ class DropdownUser extends gl.FilteredSearchDropdown { ...@@ -43,7 +44,7 @@ class DropdownUser extends gl.FilteredSearchDropdown {
getSearchInput() { getSearchInput() {
const query = gl.DropdownUtils.getSearchInput(this.input); const query = gl.DropdownUtils.getSearchInput(this.input);
const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query); const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get());
let value = lastToken || ''; let value = lastToken || '';
......
...@@ -50,10 +50,12 @@ class DropdownUtils { ...@@ -50,10 +50,12 @@ class DropdownUtils {
return updatedItem; return updatedItem;
} }
static filterHint(input, item) { static filterHint(config, item) {
const { input, allowedKeys } = config;
const updatedItem = item; const updatedItem = item;
const searchInput = gl.DropdownUtils.getSearchQuery(input); const searchInput = gl.DropdownUtils.getSearchQuery(input);
const { lastToken, tokens } = gl.FilteredSearchTokenizer.processTokens(searchInput); const { lastToken, tokens } =
gl.FilteredSearchTokenizer.processTokens(searchInput, allowedKeys);
const lastKey = lastToken.key || lastToken || ''; const lastKey = lastToken.key || lastToken || '';
const allowMultiple = item.type === 'array'; const allowMultiple = item.type === 'array';
const itemInExistingTokens = tokens.some(t => t.key === item.hint); const itemInExistingTokens = tokens.some(t => t.key === item.hint);
......
...@@ -10,4 +10,4 @@ import './filtered_search_tokenizer'; ...@@ -10,4 +10,4 @@ import './filtered_search_tokenizer';
import './filtered_search_visual_tokens'; import './filtered_search_visual_tokens';
// EE-only // EE-only
import './filtered_search_token_keys_with_weights'; import './filtered_search_token_keys_issues_ee';
...@@ -2,16 +2,16 @@ import DropLab from '~/droplab/drop_lab'; ...@@ -2,16 +2,16 @@ import DropLab from '~/droplab/drop_lab';
import FilteredSearchContainer from './container'; import FilteredSearchContainer from './container';
class FilteredSearchDropdownManager { class FilteredSearchDropdownManager {
constructor(baseEndpoint = '', page) { constructor(baseEndpoint = '', tokenizer, page) {
this.container = FilteredSearchContainer.container; this.container = FilteredSearchContainer.container;
this.baseEndpoint = baseEndpoint.replace(/\/$/, ''); this.baseEndpoint = baseEndpoint.replace(/\/$/, '');
this.tokenizer = gl.FilteredSearchTokenizer; this.tokenizer = tokenizer;
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.page = page; this.page = page;
if (this.page === 'issues' || this.page === 'boards') { if (this.page === 'issues' || this.page === 'boards') {
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeysWithWeights; this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeysIssuesEE;
} }
this.setupMapping(); this.setupMapping();
...@@ -66,7 +66,7 @@ class FilteredSearchDropdownManager { ...@@ -66,7 +66,7 @@ class FilteredSearchDropdownManager {
this.mapping.weight = { this.mapping.weight = {
reference: null, reference: null,
gl: 'DropdownNonUser', gl: 'DropdownNonUser',
element: document.querySelector('#js-dropdown-weight'), element: this.container.querySelector('#js-dropdown-weight'),
}; };
} }
} }
...@@ -110,7 +110,8 @@ class FilteredSearchDropdownManager { ...@@ -110,7 +110,8 @@ class FilteredSearchDropdownManager {
if (!mappingKey.reference) { if (!mappingKey.reference) {
const dl = this.droplab; const dl = this.droplab;
const defaultArguments = [null, dl, element, this.filteredSearchInput, key]; const defaultArguments =
[null, dl, element, this.filteredSearchInput, this.filteredSearchTokenKeys, key];
const glArguments = defaultArguments.concat(mappingKey.extraArguments || []); const glArguments = defaultArguments.concat(mappingKey.extraArguments || []);
// Passing glArguments to `new gl[glClass](<arguments>)` // Passing glArguments to `new gl[glClass](<arguments>)`
...@@ -153,7 +154,8 @@ class FilteredSearchDropdownManager { ...@@ -153,7 +154,8 @@ class FilteredSearchDropdownManager {
setDropdown() { setDropdown() {
const query = gl.DropdownUtils.getSearchQuery(true); const query = gl.DropdownUtils.getSearchQuery(true);
const { lastToken, searchToken } = this.tokenizer.processTokens(query); const { lastToken, searchToken } =
this.tokenizer.processTokens(query, this.filteredSearchTokenKeys.getKeys());
if (this.currentDropdown) { if (this.currentDropdown) {
this.updateCurrentDropdownOffset(); this.updateCurrentDropdownOffset();
......
...@@ -14,11 +14,12 @@ class FilteredSearchManager { ...@@ -14,11 +14,12 @@ class FilteredSearchManager {
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
if (page === 'issues' || page === 'boards') { if (page === 'issues' || page === 'boards') {
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeysWithWeights; this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeysIssuesEE;
} }
this.recentSearchesStore = new RecentSearchesStore({ this.recentSearchesStore = new RecentSearchesStore({
isLocalStorageAvailable: RecentSearchesService.isAvailable(), isLocalStorageAvailable: RecentSearchesService.isAvailable(),
allowedKeys: this.filteredSearchTokenKeys.getKeys(),
}); });
const searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown'); const searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown');
const projectPath = searchHistoryDropdownElement ? const projectPath = searchHistoryDropdownElement ?
...@@ -50,7 +51,7 @@ class FilteredSearchManager { ...@@ -50,7 +51,7 @@ class FilteredSearchManager {
if (this.filteredSearchInput) { if (this.filteredSearchInput) {
this.tokenizer = gl.FilteredSearchTokenizer; this.tokenizer = gl.FilteredSearchTokenizer;
this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page); this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', this.tokenizer, page);
this.recentSearchesRoot = new RecentSearchesRoot( this.recentSearchesRoot = new RecentSearchesRoot(
this.recentSearchesStore, this.recentSearchesStore,
...@@ -326,7 +327,7 @@ class FilteredSearchManager { ...@@ -326,7 +327,7 @@ class FilteredSearchManager {
handleInputVisualToken() { handleInputVisualToken() {
const input = this.filteredSearchInput; const input = this.filteredSearchInput;
const { tokens, searchToken } const { tokens, searchToken }
= gl.FilteredSearchTokenizer.processTokens(input.value); = this.tokenizer.processTokens(input.value, this.filteredSearchTokenKeys.getKeys());
const { isLastVisualTokenValid } const { isLastVisualTokenValid }
= gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
...@@ -452,7 +453,7 @@ class FilteredSearchManager { ...@@ -452,7 +453,7 @@ class FilteredSearchManager {
this.saveCurrentSearchQuery(); this.saveCurrentSearchQuery();
const { tokens, searchToken } const { tokens, searchToken }
= this.tokenizer.processTokens(searchQuery); = this.tokenizer.processTokens(searchQuery, this.filteredSearchTokenKeys.getKeys());
const currentState = gl.utils.getParameterByName('state') || 'opened'; const currentState = gl.utils.getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`); paths.push(`state=${currentState}`);
......
...@@ -3,21 +3,25 @@ const tokenKeys = [{ ...@@ -3,21 +3,25 @@ const tokenKeys = [{
type: 'string', type: 'string',
param: 'username', param: 'username',
symbol: '@', symbol: '@',
icon: 'pencil',
}, { }, {
key: 'assignee', key: 'assignee',
type: 'string', type: 'string',
param: 'username', param: 'username',
symbol: '@', symbol: '@',
icon: 'user',
}, { }, {
key: 'milestone', key: 'milestone',
type: 'string', type: 'string',
param: 'title', param: 'title',
symbol: '%', symbol: '%',
icon: 'clock-o',
}, { }, {
key: 'label', key: 'label',
type: 'array', type: 'array',
param: 'name[]', param: 'name[]',
symbol: '~', symbol: '~',
icon: 'tag',
}]; }];
const alternativeTokenKeys = [{ const alternativeTokenKeys = [{
...@@ -56,6 +60,10 @@ class FilteredSearchTokenKeys { ...@@ -56,6 +60,10 @@ class FilteredSearchTokenKeys {
return tokenKeys; return tokenKeys;
} }
static getKeys() {
return tokenKeys.map(i => i.key);
}
static getAlternatives() { static getAlternatives() {
return alternativeTokenKeys; return alternativeTokenKeys;
} }
......
...@@ -5,6 +5,7 @@ const weightTokenKey = { ...@@ -5,6 +5,7 @@ const weightTokenKey = {
type: 'string', type: 'string',
param: '', param: '',
symbol: '', symbol: '',
icon: 'balance-scale',
}; };
const weightConditions = [{ const weightConditions = [{
...@@ -17,13 +18,23 @@ const weightConditions = [{ ...@@ -17,13 +18,23 @@ const weightConditions = [{
value: 'any', value: 'any',
}]; }];
class FilteredSearchTokenKeysWithWeights extends gl.FilteredSearchTokenKeys { class FilteredSearchTokenKeysIssuesEE extends gl.FilteredSearchTokenKeys {
static get() { static get() {
const tokenKeys = super.get(); const tokenKeys = Array.from(super.get());
// Enable multiple assignees
const assigneeTokenKey = tokenKeys.find(tk => tk.key === 'assignee');
assigneeTokenKey.type = 'array';
tokenKeys.push(weightTokenKey); tokenKeys.push(weightTokenKey);
return tokenKeys; return tokenKeys;
} }
static getKeys() {
const tokenKeys = FilteredSearchTokenKeysIssuesEE.get();
return tokenKeys.map(i => i.key);
}
static getAlternatives() { static getAlternatives() {
return super.getAlternatives(); return super.getAlternatives();
} }
...@@ -34,18 +45,18 @@ class FilteredSearchTokenKeysWithWeights extends gl.FilteredSearchTokenKeys { ...@@ -34,18 +45,18 @@ class FilteredSearchTokenKeysWithWeights extends gl.FilteredSearchTokenKeys {
} }
static searchByKey(key) { static searchByKey(key) {
const tokenKeys = FilteredSearchTokenKeysWithWeights.get(); const tokenKeys = FilteredSearchTokenKeysIssuesEE.get();
return tokenKeys.find(tokenKey => tokenKey.key === key) || null; return tokenKeys.find(tokenKey => tokenKey.key === key) || null;
} }
static searchBySymbol(symbol) { static searchBySymbol(symbol) {
const tokenKeys = FilteredSearchTokenKeysWithWeights.get(); const tokenKeys = FilteredSearchTokenKeysIssuesEE.get();
return tokenKeys.find(tokenKey => tokenKey.symbol === symbol) || null; return tokenKeys.find(tokenKey => tokenKey.symbol === symbol) || null;
} }
static searchByKeyParam(keyParam) { static searchByKeyParam(keyParam) {
const tokenKeys = FilteredSearchTokenKeysWithWeights.get(); const tokenKeys = FilteredSearchTokenKeysIssuesEE.get();
const alternativeTokenKeys = FilteredSearchTokenKeysWithWeights.getAlternatives(); const alternativeTokenKeys = FilteredSearchTokenKeysIssuesEE.getAlternatives();
const tokenKeysWithAlternative = tokenKeys.concat(alternativeTokenKeys); const tokenKeysWithAlternative = tokenKeys.concat(alternativeTokenKeys);
return tokenKeysWithAlternative.find((tokenKey) => { return tokenKeysWithAlternative.find((tokenKey) => {
...@@ -60,16 +71,16 @@ class FilteredSearchTokenKeysWithWeights extends gl.FilteredSearchTokenKeys { ...@@ -60,16 +71,16 @@ class FilteredSearchTokenKeysWithWeights extends gl.FilteredSearchTokenKeys {
} }
static searchByConditionUrl(url) { static searchByConditionUrl(url) {
const conditions = FilteredSearchTokenKeysWithWeights.getConditions(); const conditions = FilteredSearchTokenKeysIssuesEE.getConditions();
return conditions.find(condition => condition.url === url) || null; return conditions.find(condition => condition.url === url) || null;
} }
static searchByConditionKeyValue(key, value) { static searchByConditionKeyValue(key, value) {
const conditions = FilteredSearchTokenKeysWithWeights.getConditions(); const conditions = FilteredSearchTokenKeysIssuesEE.getConditions();
return conditions return conditions
.find(condition => condition.tokenKey === key && condition.value === value) || null; .find(condition => condition.tokenKey === key && condition.value === value) || null;
} }
} }
window.gl = window.gl || {}; window.gl = window.gl || {};
gl.FilteredSearchTokenKeysWithWeights = FilteredSearchTokenKeysWithWeights; gl.FilteredSearchTokenKeysIssuesEE = FilteredSearchTokenKeysIssuesEE;
import './filtered_search_token_keys'; import './filtered_search_token_keys';
class FilteredSearchTokenizer { class FilteredSearchTokenizer {
static processTokens(input) { static processTokens(input, allowedKeys) {
const allowedKeys = gl.FilteredSearchTokenKeys.get().map(i => i.key);
// Regex extracts `(token):(symbol)(value)` // Regex extracts `(token):(symbol)(value)`
// Values that start with a double quote must end in a double quote (same for single) // Values that start with a double quote must end in a double quote (same for single)
const tokenRegex = new RegExp(`(${allowedKeys.join('|')}):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, 'g'); const tokenRegex = new RegExp(`(${allowedKeys.join('|')}):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, 'g');
......
...@@ -37,6 +37,7 @@ class RecentSearchesRoot { ...@@ -37,6 +37,7 @@ class RecentSearchesRoot {
<recent-searches-dropdown-content <recent-searches-dropdown-content
:items="recentSearches" :items="recentSearches"
:is-local-storage-available="isLocalStorageAvailable" :is-local-storage-available="isLocalStorageAvailable"
:allowed-keys="allowedKeys"
/> />
`, `,
components: { components: {
......
import _ from 'underscore'; import _ from 'underscore';
class RecentSearchesStore { class RecentSearchesStore {
constructor(initialState = {}) { constructor(initialState = {}, allowedKeys) {
this.state = Object.assign({ this.state = Object.assign({
isLocalStorageAvailable: true, isLocalStorageAvailable: true,
recentSearches: [], recentSearches: [],
allowedKeys,
}, initialState); }, initialState);
} }
......
...@@ -16,7 +16,8 @@ window.Flash = (function() { ...@@ -16,7 +16,8 @@ window.Flash = (function() {
parent = null; parent = null;
} }
if (parent) { if (parent) {
this.flashContainer = parent.find('.flash-container'); const $parent = $(parent);
this.flashContainer = $parent.find('.flash-container');
} else { } else {
this.flashContainer = $('.flash-container-page'); this.flashContainer = $('.flash-container-page');
} }
...@@ -37,5 +38,9 @@ window.Flash = (function() { ...@@ -37,5 +38,9 @@ window.Flash = (function() {
this.flashContainer.show(); this.flashContainer.show();
} }
Flash.prototype.destroy = function() {
this.flashContainer.html('');
};
return Flash; return Flash;
})(); })();
...@@ -7,9 +7,10 @@ import GfmAutoComplete from './gfm_auto_complete'; ...@@ -7,9 +7,10 @@ import GfmAutoComplete from './gfm_auto_complete';
window.gl = window.gl || {}; window.gl = window.gl || {};
function GLForm(form) { function GLForm(form, enableGFM = false) {
this.form = form; this.form = form;
this.textarea = this.form.find('textarea.js-gfm-input'); this.textarea = this.form.find('textarea.js-gfm-input');
this.enableGFM = enableGFM;
// Before we start, we should clean up any previous data for this form // Before we start, we should clean up any previous data for this form
this.destroy(); this.destroy();
// Setup the form // Setup the form
...@@ -32,8 +33,14 @@ GLForm.prototype.setupForm = function() { ...@@ -32,8 +33,14 @@ GLForm.prototype.setupForm = function() {
this.form.addClass('gfm-form'); this.form.addClass('gfm-form');
// remove notify commit author checkbox for non-commit notes // remove notify commit author checkbox for non-commit notes
gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button, .js-note-new-discussion')); gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button, .js-note-new-discussion'));
new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources).setup(this.form.find('.js-gfm-input'), {
new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources).setup(this.form.find('.js-gfm-input')); emojis: true,
members: this.enableGFM,
issues: this.enableGFM,
milestones: this.enableGFM,
mergeRequests: this.enableGFM,
labels: this.enableGFM,
});
new DropzoneInput(this.form); new DropzoneInput(this.form);
autosize(this.textarea); autosize(this.textarea);
} }
......
...@@ -44,18 +44,18 @@ export default class GroupName { ...@@ -44,18 +44,18 @@ export default class GroupName {
showToggle() { showToggle() {
this.title.classList.add('wrap'); this.title.classList.add('wrap');
this.toggle.classList.remove('hidden'); this.toggle.classList.remove('hidden');
if (this.isHidden) this.groupTitle.classList.add('is-hidden'); if (this.isHidden) this.groupTitle.classList.add('hidden');
} }
hideToggle() { hideToggle() {
this.title.classList.remove('wrap'); this.title.classList.remove('wrap');
this.toggle.classList.add('hidden'); this.toggle.classList.add('hidden');
if (this.isHidden) this.groupTitle.classList.remove('is-hidden'); if (this.isHidden) this.groupTitle.classList.remove('hidden');
} }
toggleGroups() { toggleGroups() {
this.isHidden = !this.isHidden; this.isHidden = !this.isHidden;
this.groupTitle.classList.toggle('is-hidden'); this.groupTitle.classList.toggle('hidden');
} }
render() { render() {
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
prefer-arrow-callback, comma-dangle, consistent-return, yoda, prefer-arrow-callback, comma-dangle, consistent-return, yoda,
prefer-rest-params, prefer-spread, no-unused-vars, prefer-template, prefer-rest-params, prefer-spread, no-unused-vars, prefer-template,
promise/catch-or-return */ promise/catch-or-return */
/* global Api */ import Api from './api';
var slice = [].slice; var slice = [].slice;
......
/* eslint-disable space-before-function-paren, prefer-arrow-callback, no-var, one-var, one-var-declaration-per-line, object-shorthand, quotes, comma-dangle, consistent-return, no-unused-vars, padded-blocks, func-names, max-len */ /* eslint-disable space-before-function-paren, prefer-arrow-callback, no-var, one-var, one-var-declaration-per-line, object-shorthand, quotes, comma-dangle, consistent-return, no-unused-vars, padded-blocks, func-names, max-len */
/* global Api */ import Api from './api';
(function() { (function() {
$(function() { $(function() {
......
class AjaxCache { import Cache from './cache';
class AjaxCache extends Cache {
constructor() { constructor() {
this.internalStorage = { }; super();
this.pendingRequests = { }; this.pendingRequests = { };
} }
get(endpoint) {
return this.internalStorage[endpoint];
}
hasData(endpoint) {
return Object.prototype.hasOwnProperty.call(this.internalStorage, endpoint);
}
remove(endpoint) {
delete this.internalStorage[endpoint];
}
retrieve(endpoint) { retrieve(endpoint) {
if (this.hasData(endpoint)) { if (this.hasData(endpoint)) {
return Promise.resolve(this.get(endpoint)); return Promise.resolve(this.get(endpoint));
......
class Cache {
constructor() {
this.internalStorage = { };
}
get(key) {
return this.internalStorage[key];
}
hasData(key) {
return Object.prototype.hasOwnProperty.call(this.internalStorage, key);
}
remove(key) {
delete this.internalStorage[key];
}
}
export default Cache;
...@@ -198,10 +198,12 @@ ...@@ -198,10 +198,12 @@
const textBefore = value.substring(0, selectionStart); const textBefore = value.substring(0, selectionStart);
const textAfter = value.substring(selectionEnd, value.length); const textAfter = value.substring(selectionEnd, value.length);
const newText = textBefore + text + textAfter;
const insertedText = text instanceof Function ? text(textBefore, textAfter) : text;
const newText = textBefore + insertedText + textAfter;
target.value = newText; target.value = newText;
target.selectionStart = target.selectionEnd = selectionStart + text.length; target.selectionStart = target.selectionEnd = selectionStart + insertedText.length;
// Trigger autosave // Trigger autosave
$(target).trigger('input'); $(target).trigger('input');
......
import Api from '../../api';
import Cache from './cache';
class UsersCache extends Cache {
retrieve(username) {
if (this.hasData(username)) {
return Promise.resolve(this.get(username));
}
return Api.users('', { username })
.then((users) => {
if (!users.length) {
throw new Error(`User "${username}" could not be found!`);
}
if (users.length > 1) {
throw new Error(`Expected username "${username}" to be unique!`);
}
const user = users[0];
this.internalStorage[username] = user;
return user;
});
// missing catch is intentional, error handling depends on use case
}
}
export default new UsersCache();
This diff is collapsed.
...@@ -288,7 +288,11 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; ...@@ -288,7 +288,11 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
if (anchor) { if (anchor) {
const notesContent = anchor.closest('.notes_content'); const notesContent = anchor.closest('.notes_content');
const lineType = notesContent.hasClass('new') ? 'new' : 'old'; const lineType = notesContent.hasClass('new') ? 'new' : 'old';
notes.addDiffNote(anchor, lineType, false); notes.toggleDiffNote({
target: anchor,
lineType,
forceShow: true,
});
anchor[0].scrollIntoView(); anchor[0].scrollIntoView();
// We have multiple elements on the page with `#note_xxx` // We have multiple elements on the page with `#note_xxx`
// (discussion and diff tabs) and `:target` only applies to the first // (discussion and diff tabs) and `:target` only applies to the first
......
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, vars-on-top, one-var-declaration-per-line, comma-dangle, object-shorthand, no-else-return, prefer-template, quotes, prefer-arrow-callback, no-param-reassign, no-cond-assign, max-len */ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, vars-on-top, one-var-declaration-per-line, comma-dangle, object-shorthand, no-else-return, prefer-template, quotes, prefer-arrow-callback, no-param-reassign, no-cond-assign, max-len */
/* global Api */ import Api from './api';
(function() { (function() {
window.NamespaceSelect = (function() { window.NamespaceSelect = (function() {
......
...@@ -29,7 +29,7 @@ const normalizeNewlines = function(str) { ...@@ -29,7 +29,7 @@ const normalizeNewlines = function(str) {
Notes.interval = null; Notes.interval = null;
function Notes(notes_url, note_ids, last_fetched_at, view) { function Notes(notes_url, note_ids, last_fetched_at, view, enableGFM = true) {
this.updateTargetButtons = this.updateTargetButtons.bind(this); this.updateTargetButtons = this.updateTargetButtons.bind(this);
this.updateComment = this.updateComment.bind(this); this.updateComment = this.updateComment.bind(this);
this.visibilityChange = this.visibilityChange.bind(this); this.visibilityChange = this.visibilityChange.bind(this);
...@@ -52,6 +52,7 @@ const normalizeNewlines = function(str) { ...@@ -52,6 +52,7 @@ const normalizeNewlines = function(str) {
this.notes_url = notes_url; this.notes_url = notes_url;
this.note_ids = note_ids; this.note_ids = note_ids;
this.enableGFM = enableGFM;
// Used to keep track of updated notes while people are editing things // Used to keep track of updated notes while people are editing things
this.updatedNotesTrackingMap = {}; this.updatedNotesTrackingMap = {};
this.last_fetched_at = last_fetched_at; this.last_fetched_at = last_fetched_at;
...@@ -288,6 +289,13 @@ const normalizeNewlines = function(str) { ...@@ -288,6 +289,13 @@ const normalizeNewlines = function(str) {
} }
}; };
Notes.prototype.setupNewNote = function($note) {
// Update datetime format on the recent note
gl.utils.localTimeAgo($note.find('.js-timeago'), false);
this.collapseLongCommitList();
this.taskList.init();
};
/* /*
Render note in main comments area. Render note in main comments area.
...@@ -308,20 +316,17 @@ const normalizeNewlines = function(str) { ...@@ -308,20 +316,17 @@ const normalizeNewlines = function(str) {
} }
const $note = $notesList.find(`#note_${noteEntity.id}`); const $note = $notesList.find(`#note_${noteEntity.id}`);
if (this.isNewNote(noteEntity)) { if (Notes.isNewNote(noteEntity, this.note_ids)) {
this.note_ids.push(noteEntity.id); this.note_ids.push(noteEntity.id);
const $newNote = Notes.animateAppendNote(noteEntity.html, $notesList); const $newNote = Notes.animateAppendNote(noteEntity.html, $notesList);
// Update datetime format on the recent note this.setupNewNote($newNote);
gl.utils.localTimeAgo($newNote.find('.js-timeago'), false);
this.collapseLongCommitList();
this.taskList.init();
this.refresh(); this.refresh();
return this.updateNotesCount(1); return this.updateNotesCount(1);
} }
// The server can send the same update multiple times so we need to make sure to only update once per actual update. // The server can send the same update multiple times so we need to make sure to only update once per actual update.
else if (this.isUpdatedNote(noteEntity, $note)) { else if (Notes.isUpdatedNote(noteEntity, $note)) {
const isEditing = $note.hasClass('is-editing'); const isEditing = $note.hasClass('is-editing');
const initialContent = normalizeNewlines( const initialContent = normalizeNewlines(
$note.find('.original-note-content').text().trim() $note.find('.original-note-content').text().trim()
...@@ -342,30 +347,11 @@ const normalizeNewlines = function(str) { ...@@ -342,30 +347,11 @@ const normalizeNewlines = function(str) {
} }
else { else {
const $updatedNote = Notes.animateUpdateNote(noteEntity.html, $note); const $updatedNote = Notes.animateUpdateNote(noteEntity.html, $note);
this.setupNewNote($updatedNote);
// Update datetime format on the recent note
gl.utils.localTimeAgo($updatedNote.find('.js-timeago'), false);
} }
} }
}; };
/*
Check if note does not exists on page
*/
Notes.prototype.isNewNote = function(noteEntity) {
return $.inArray(noteEntity.id, this.note_ids) === -1;
};
Notes.prototype.isUpdatedNote = function(noteEntity, $note) {
// There can be CRLF vs LF mismatches if we don't sanitize and compare the same way
const sanitizedNoteNote = normalizeNewlines(noteEntity.note);
const currentNoteText = normalizeNewlines(
$note.find('.original-note-content').text().trim()
);
return sanitizedNoteNote !== currentNoteText;
};
Notes.prototype.isParallelView = function() { Notes.prototype.isParallelView = function() {
return Cookies.get('diff_view') === 'parallel'; return Cookies.get('diff_view') === 'parallel';
}; };
...@@ -378,7 +364,7 @@ const normalizeNewlines = function(str) { ...@@ -378,7 +364,7 @@ const normalizeNewlines = function(str) {
Notes.prototype.renderDiscussionNote = function(noteEntity, $form) { Notes.prototype.renderDiscussionNote = function(noteEntity, $form) {
var discussionContainer, form, row, lineType, diffAvatarContainer; var discussionContainer, form, row, lineType, diffAvatarContainer;
if (!this.isNewNote(noteEntity)) { if (!Notes.isNewNote(noteEntity, this.note_ids)) {
return; return;
} }
this.note_ids.push(noteEntity.id); this.note_ids.push(noteEntity.id);
...@@ -525,7 +511,7 @@ const normalizeNewlines = function(str) { ...@@ -525,7 +511,7 @@ const normalizeNewlines = function(str) {
Notes.prototype.setupNoteForm = function(form) { Notes.prototype.setupNoteForm = function(form) {
var textarea, key; var textarea, key;
new gl.GLForm(form); new gl.GLForm(form, this.enableGFM);
textarea = form.find(".js-note-text"); textarea = form.find(".js-note-text");
key = [ key = [
"Note", "Note",
...@@ -596,12 +582,12 @@ const normalizeNewlines = function(str) { ...@@ -596,12 +582,12 @@ const normalizeNewlines = function(str) {
Updates the current note field. Updates the current note field.
*/ */
Notes.prototype.updateNote = function(_xhr, noteEntity, _status) { Notes.prototype.updateNote = function(noteEntity, $targetNote) {
var $noteEntityEl, $note_li; var $noteEntityEl, $note_li;
// Convert returned HTML to a jQuery object so we can modify it further // Convert returned HTML to a jQuery object so we can modify it further
$noteEntityEl = $(noteEntity.html); $noteEntityEl = $(noteEntity.html);
$noteEntityEl.addClass('fade-in-full'); $noteEntityEl.addClass('fade-in-full');
this.revertNoteEditForm(); this.revertNoteEditForm($targetNote);
gl.utils.localTimeAgo($('.js-timeago', $noteEntityEl)); gl.utils.localTimeAgo($('.js-timeago', $noteEntityEl));
$noteEntityEl.renderGFM(); $noteEntityEl.renderGFM();
$noteEntityEl.find('.js-task-list-container').taskList('enable'); $noteEntityEl.find('.js-task-list-container').taskList('enable');
...@@ -683,10 +669,8 @@ const normalizeNewlines = function(str) { ...@@ -683,10 +669,8 @@ const normalizeNewlines = function(str) {
if (this.updatedNotesTrackingMap[noteId]) { if (this.updatedNotesTrackingMap[noteId]) {
const $newNote = $(this.updatedNotesTrackingMap[noteId].html); const $newNote = $(this.updatedNotesTrackingMap[noteId].html);
$note.replaceWith($newNote); $note.replaceWith($newNote);
this.setupNewNote($newNote);
this.updatedNotesTrackingMap[noteId] = null; this.updatedNotesTrackingMap[noteId] = null;
// Update datetime format on the recent note
gl.utils.localTimeAgo($newNote.find('.js-timeago'), false);
} }
else { else {
$note.find('.js-finish-edit-warning').hide(); $note.find('.js-finish-edit-warning').hide();
...@@ -876,12 +860,22 @@ const normalizeNewlines = function(str) { ...@@ -876,12 +860,22 @@ const normalizeNewlines = function(str) {
Notes.prototype.onAddDiffNote = function(e) { Notes.prototype.onAddDiffNote = function(e) {
e.preventDefault(); e.preventDefault();
const $link = $(e.currentTarget || e.target); const link = e.currentTarget || e.target;
const $link = $(link);
const showReplyInput = !$link.hasClass('js-diff-comment-avatar'); const showReplyInput = !$link.hasClass('js-diff-comment-avatar');
this.addDiffNote($link, $link.data('lineType'), showReplyInput); this.toggleDiffNote({
target: $link,
lineType: link.dataset.lineType,
showReplyInput
});
}; };
Notes.prototype.addDiffNote = function(target, lineType, showReplyInput) { Notes.prototype.toggleDiffNote = function({
target,
lineType,
forceShow,
showReplyInput = false,
}) {
var $link, addForm, hasNotes, newForm, noteForm, replyButton, row, rowCssToAdd, targetContent, isDiffCommentAvatar; var $link, addForm, hasNotes, newForm, noteForm, replyButton, row, rowCssToAdd, targetContent, isDiffCommentAvatar;
$link = $(target); $link = $(target);
row = $link.closest("tr"); row = $link.closest("tr");
...@@ -926,12 +920,12 @@ const normalizeNewlines = function(str) { ...@@ -926,12 +920,12 @@ const normalizeNewlines = function(str) {
notesContent = targetRow.find(notesContentSelector); notesContent = targetRow.find(notesContentSelector);
addForm = true; addForm = true;
} else { } else {
targetRow.show(); const isCurrentlyShown = targetRow.find('.content:not(:empty)').is(':visible');
notesContent.toggle(!notesContent.is(':visible')); const isForced = forceShow === true || forceShow === false;
const showNow = forceShow === true || (!isCurrentlyShown && !isForced);
if (!targetRow.find('.content:not(:empty)').is(':visible')) { targetRow.toggle(showNow);
targetRow.hide(); notesContent.toggle(showNow);
}
} }
if (addForm) { if (addForm) {
...@@ -1139,6 +1133,25 @@ const normalizeNewlines = function(str) { ...@@ -1139,6 +1133,25 @@ const normalizeNewlines = function(str) {
return $form; return $form;
}; };
/**
* Check if note does not exists on page
*/
Notes.isNewNote = function(noteEntity, noteIds) {
return $.inArray(noteEntity.id, noteIds) === -1;
};
/**
* Check if $note already contains the `noteEntity` content
*/
Notes.isUpdatedNote = function(noteEntity, $note) {
// There can be CRLF vs LF mismatches if we don't sanitize and compare the same way
const sanitizedNoteEntityText = normalizeNewlines(noteEntity.note.trim());
const currentNoteText = normalizeNewlines(
$note.find('.original-note-content').first().text().trim()
);
return sanitizedNoteEntityText !== currentNoteText;
};
Notes.checkMergeRequestStatus = function() { Notes.checkMergeRequestStatus = function() {
if (gl.utils.getPagePath(1) === 'merge_requests') { if (gl.utils.getPagePath(1) === 'merge_requests') {
gl.mrWidget.checkStatus(); gl.mrWidget.checkStatus();
...@@ -1394,7 +1407,7 @@ const normalizeNewlines = function(str) { ...@@ -1394,7 +1407,7 @@ const normalizeNewlines = function(str) {
gl.utils.ajaxPost(formAction, formData) gl.utils.ajaxPost(formAction, formData)
.then((note) => { .then((note) => {
// Submission successful! render final note element // Submission successful! render final note element
this.updateNote(null, note, null); this.updateNote(note, $editingNote);
}) })
.fail(() => { .fail(() => {
// Submission failed, revert back to original note // Submission failed, revert back to original note
......
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
export default { export default {
props: [ props: [
'pipeline', 'pipeline',
...@@ -7,6 +9,9 @@ export default { ...@@ -7,6 +9,9 @@ export default {
return !!this.pipeline.user; return !!this.pipeline.user;
}, },
}, },
components: {
userAvatarLink,
},
template: ` template: `
<td> <td>
<a <a
...@@ -15,18 +20,13 @@ export default { ...@@ -15,18 +20,13 @@ export default {
<span class="pipeline-id">#{{pipeline.id}}</span> <span class="pipeline-id">#{{pipeline.id}}</span>
</a> </a>
<span>by</span> <span>by</span>
<a <user-avatar-link
class="js-pipeline-url-user"
v-if="user" v-if="user"
:href="pipeline.user.web_url"> class="js-pipeline-url-user"
<img :link-href="pipeline.user.web_url"
v-if="user" :img-src="pipeline.user.avatar_url"
class="avatar has-tooltip s20 " :tooltip-text="pipeline.user.name"
:title="pipeline.user.name" />
data-container="body"
:src="pipeline.user.avatar_url"
>
</a>
<span <span
v-if="!user" v-if="!user"
class="js-pipeline-url-api api"> class="js-pipeline-url-api api">
......
export default function setupProjectEdit() {
const $transferForm = $('.js-project-transfer-form');
const $selectNamespace = $transferForm.find('.select2');
$selectNamespace.on('change', () => {
$transferForm.find(':submit').prop('disabled', !$selectNamespace.val());
});
$selectNamespace.trigger('change');
}
/* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, no-var, comma-dangle, object-shorthand, one-var, one-var-declaration-per-line, no-else-return, quotes, max-len */ /* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, no-var, comma-dangle, object-shorthand, one-var, one-var-declaration-per-line, no-else-return, quotes, max-len */
/* global Api */ import Api from './api';
(function() { (function() {
this.ProjectSelect = (function() { this.ProjectSelect = (function() {
......
<script>
/* global Flash */
import serviceDeskSetting from './service_desk_setting.vue';
import ServiceDeskStore from '../stores/service_desk_store';
import ServiceDeskService from '../services/service_desk_service';
import eventHub from '../event_hub';
export default {
name: 'ServiceDeskRoot',
props: {
initialIsEnabled: {
type: Boolean,
required: true,
},
endpoint: {
type: String,
required: true,
},
incomingEmail: {
type: String,
required: false,
default: '',
},
},
data() {
const store = new ServiceDeskStore({
incomingEmail: this.incomingEmail,
});
return {
store,
state: store.state,
isEnabled: this.initialIsEnabled,
};
},
components: {
serviceDeskSetting,
},
methods: {
fetchIncomingEmail() {
if (this.flash) {
this.flash.destroy();
}
this.service.fetchIncomingEmail()
.then(res => res.json())
.then((data) => {
const email = data.service_desk_address;
if (!email) {
throw new Error('Response didn\'t include `service_desk_address`');
}
this.store.setIncomingEmail(email);
})
.catch(() => {
this.flash = new Flash('An error occurred while fetching the Service Desk address.', 'alert', this.$el);
});
},
onEnableToggled(isChecked) {
this.isEnabled = isChecked;
this.store.resetIncomingEmail();
if (this.flash) {
this.flash.destroy();
}
this.service.toggleServiceDesk(isChecked)
.then(res => res.json())
.then((data) => {
const email = data.service_desk_address;
if (isChecked && !email) {
throw new Error('Response didn\'t include `service_desk_address`');
}
this.store.setIncomingEmail(email);
})
.catch(() => {
const verb = isChecked ? 'enabling' : 'disabling';
this.flash = new Flash(`An error occurred while ${verb} Service Desk.`, 'alert', this.$el);
});
},
},
created() {
eventHub.$on('serviceDeskEnabledCheckboxToggled', this.onEnableToggled);
this.service = new ServiceDeskService(this.endpoint);
if (this.isEnabled && !this.store.state.incomingEmail) {
this.fetchIncomingEmail();
}
},
beforeDestroy() {
eventHub.$off('serviceDeskEnabledCheckboxToggled', this.onEnableToggled);
},
};
</script>
<template>
<div>
<div class="flash-container"></div>
<service-desk-setting
:is-enabled="isEnabled"
:incoming-email="state.incomingEmail" />
</div>
</template>
import eventHub from '../event_hub';
export default {
name: 'ServiceDeskSetting',
props: {
isEnabled: {
type: Boolean,
required: true,
},
incomingEmail: {
type: String,
required: false,
default: '',
},
fetchError: {
type: Error,
required: false,
default: null,
},
},
methods: {
onCheckboxToggle(e) {
const isChecked = e.target.checked;
eventHub.$emit('serviceDeskEnabledCheckboxToggled', isChecked);
},
},
template: `
<div>
<div class="checkbox">
<label for="service-desk-enabled-checkbox">
<input
ref="enabled-checkbox"
type="checkbox"
id="service-desk-enabled-checkbox"
:checked="isEnabled"
@change="onCheckboxToggle($event)">
<span class="descr">
Activate Service Desk
</span>
</label>
</div>
<template v-if="isEnabled">
<div
class="panel-slim panel-default">
<div class="panel-heading">
<h3 class="panel-title">
Forward external support email address to:
</h3>
</div>
<div class="panel-body">
<template v-if="fetchError">
<i class="fa fa-exclamation-circle" aria-hidden="true" />
An error occurred while fetching the incoming email
</template>
<template v-else-if="incomingEmail">
<span
ref="service-desk-incoming-email">
{{ incomingEmail }}
</span>
<button
class="btn btn-clipboard btn-transparent has-tooltip"
title="Copy incoming email address to clipboard"
:data-clipboard-text="incomingEmail"
@click.prevent>
<i class="fa fa-clipboard" aria-hidden="true" />
</button>
</template>
<template v-else>
<i class="fa fa-spinner fa-spin" aria-hidden="true" />
<span class="sr-only">
Fetching incoming email
</span>
</template>
</div>
</div>
</template>
</div>
`,
};
<script>
import eventHub from '../event_hub';
export default {
name: 'ServiceDeskSetting',
props: {
isEnabled: {
type: Boolean,
required: true,
},
incomingEmail: {
type: String,
required: false,
default: '',
},
},
methods: {
onCheckboxToggle(e) {
const isChecked = e.target.checked;
eventHub.$emit('serviceDeskEnabledCheckboxToggled', isChecked);
},
},
};
</script>
<template>
<div>
<div class="checkbox">
<label for="service-desk-enabled-checkbox">
<input
ref="enabled-checkbox"
type="checkbox"
id="service-desk-enabled-checkbox"
:checked="isEnabled"
@change="onCheckboxToggle($event)">
<span class="descr">
Activate Service Desk
</span>
</label>
</div>
<div
v-if="isEnabled"
class="panel-slim panel-default">
<div class="panel-heading">
<h3 class="panel-title">
Forward external support email address to:
</h3>
</div>
<div class="panel-body">
<template v-if="incomingEmail">
<span
ref="service-desk-incoming-email">
{{ incomingEmail }}
</span>
<button
type="button"
class="btn btn-clipboard btn-transparent has-tooltip"
title="Copy incoming email address to clipboard"
:data-clipboard-text="incomingEmail">
<i
class="fa fa-clipboard"
aria-hidden="true" />
</button>
</template>
<template v-else>
<i
class="fa fa-spinner fa-spin"
aria-hidden="true">
</i>
<span class="sr-only">
Fetching incoming email
</span>
</template>
</div>
</div>
</div>
</template>
import Vue from 'vue';
import serviceDeskRoot from './components/service_desk_root.vue';
document.addEventListener('DOMContentLoaded', () => {
const serviceDeskRootElement = document.querySelector('.js-service-desk-setting-root');
if (serviceDeskRootElement) {
// eslint-disable-next-line no-new
new Vue({
el: serviceDeskRootElement,
data() {
const dataset = serviceDeskRootElement.dataset;
return {
initialIsEnabled: gl.utils.convertPermissionToBoolean(
dataset.enabled,
),
endpoint: dataset.endpoint,
incomingEmail: dataset.incomingEmail,
};
},
components: {
serviceDeskRoot,
},
render(createElement) {
return createElement('service-desk-root', {
props: {
initialIsEnabled: this.initialIsEnabled,
endpoint: this.endpoint,
incomingEmail: this.incomingEmail,
},
});
},
});
}
});
/* eslint-disable no-new */
import Vue from 'vue';
import ServiceDeskSetting from './components/service_desk_setting';
import ServiceDeskStore from './stores/service_desk_store';
import ServiceDeskService from './services/service_desk_service';
import eventHub from './event_hub';
class ServiceDeskRoot {
constructor(wrapperElement) {
this.wrapperElement = wrapperElement;
const isEnabled = typeof this.wrapperElement.dataset.enabled !== 'undefined' &&
this.wrapperElement.dataset.enabled !== 'false';
const incomingEmail = this.wrapperElement.dataset.incomingEmail;
const endpoint = this.wrapperElement.dataset.endpoint;
this.store = new ServiceDeskStore({
isEnabled,
incomingEmail,
});
this.service = new ServiceDeskService(endpoint);
}
init() {
this.bindEvents();
if (this.store.state.isEnabled && !this.store.state.incomingEmail) {
this.fetchIncomingEmail();
}
this.render();
}
bindEvents() {
this.onEnableToggledWrapper = this.onEnableToggled.bind(this);
eventHub.$on('serviceDeskEnabledCheckboxToggled', this.onEnableToggledWrapper);
}
unbindEvents() {
eventHub.$off('serviceDeskEnabledCheckboxToggled', this.onEnableToggledWrapper);
}
render() {
this.vm = new Vue({
el: this.wrapperElement,
data: this.store.state,
template: `
<service-desk-setting
:isEnabled="isEnabled"
:incomingEmail="incomingEmail"
:fetchError="fetchError" />
`,
components: {
'service-desk-setting': ServiceDeskSetting,
},
});
}
fetchIncomingEmail() {
this.service.fetchIncomingEmail()
.then((incomingEmail) => {
this.store.setIncomingEmail(incomingEmail);
})
.catch((err) => {
this.store.setFetchError(err);
});
}
onEnableToggled(isChecked) {
this.store.setIsActivated(isChecked);
this.store.setIncomingEmail('');
this.store.setFetchError(null);
this.service.toggleServiceDesk(isChecked)
.then((incomingEmail) => {
this.store.setIncomingEmail(incomingEmail);
})
.catch((err) => {
this.store.setFetchError(err);
});
}
destroy() {
this.unbindEvents();
if (this.vm) {
this.vm.$destroy();
}
}
}
export default ServiceDeskRoot;
import Vue from 'vue'; import Vue from 'vue';
import vueResource from 'vue-resource'; import vueResource from 'vue-resource';
import '../../../vue_shared/vue_resource_interceptor';
Vue.use(vueResource); Vue.use(vueResource);
...@@ -10,28 +9,12 @@ class ServiceDeskService { ...@@ -10,28 +9,12 @@ class ServiceDeskService {
} }
fetchIncomingEmail() { fetchIncomingEmail() {
return this.serviceDeskResource.get() return this.serviceDeskResource.get();
.then((res) => {
const email = res.data.service_desk_address;
if (!email) {
throw new Error('Response didn\'t include `service_desk_address`');
}
return email;
});
} }
toggleServiceDesk(enable) { toggleServiceDesk(enable) {
return this.serviceDeskResource.update({ return this.serviceDeskResource.update({
service_desk_enabled: enable, service_desk_enabled: enable,
})
.then((res) => {
const email = res.data.service_desk_address;
if (enable && !email) {
throw new Error('Response didn\'t include `service_desk_address`');
}
return email;
}); });
} }
} }
......
class ServiceDeskStore { class ServiceDeskStore {
constructor(initialState = {}) { constructor(initialState = {}) {
this.state = Object.assign({ this.state = Object.assign({
isEnabled: false,
incomingEmail: '', incomingEmail: '',
fetchError: null,
}, initialState); }, initialState);
} }
setIsActivated(value) {
this.state.isEnabled = value;
}
setIncomingEmail(value) { setIncomingEmail(value) {
this.state.incomingEmail = value; this.state.incomingEmail = value;
} }
setFetchError(value) { resetIncomingEmail() {
this.state.fetchError = new Error(value); this.state.incomingEmail = '';
} }
} }
......
...@@ -6,6 +6,10 @@ const index = function index() { ...@@ -6,6 +6,10 @@ const index = function index() {
currentUserId: gon.current_user_id, currentUserId: gon.current_user_id,
whitelistUrls: [gon.gitlab_url], whitelistUrls: [gon.gitlab_url],
isProduction: process.env.NODE_ENV, isProduction: process.env.NODE_ENV,
release: gon.revision,
tags: {
revision: gon.revision,
},
}); });
return RavenConfig; return RavenConfig;
......
import Raven from 'raven-js'; import Raven from 'raven-js';
import $ from 'jquery';
const IGNORE_ERRORS = [ const IGNORE_ERRORS = [
// Random plugins/extensions // Random plugins/extensions
...@@ -57,6 +58,8 @@ const RavenConfig = { ...@@ -57,6 +58,8 @@ const RavenConfig = {
configure() { configure() {
Raven.config(this.options.sentryDsn, { Raven.config(this.options.sentryDsn, {
release: this.options.release,
tags: this.options.tags,
whitelistUrls: this.options.whitelistUrls, whitelistUrls: this.options.whitelistUrls,
environment: this.options.isProduction ? 'production' : 'development', environment: this.options.isProduction ? 'production' : 'development',
ignoreErrors: this.IGNORE_ERRORS, ignoreErrors: this.IGNORE_ERRORS,
...@@ -72,7 +75,7 @@ const RavenConfig = { ...@@ -72,7 +75,7 @@ const RavenConfig = {
}, },
bindRavenErrors() { bindRavenErrors() {
window.$(document).on('ajaxError.raven', this.handleRavenErrors); $(document).on('ajaxError.raven', this.handleRavenErrors);
}, },
handleRavenErrors(event, req, config, err) { handleRavenErrors(event, req, config, err) {
......
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, one-var-declaration-per-line, object-shorthand, prefer-arrow-callback, comma-dangle, prefer-template, quotes, no-else-return, max-len */ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, one-var-declaration-per-line, object-shorthand, prefer-arrow-callback, comma-dangle, prefer-template, quotes, no-else-return, max-len */
/* global Flash */ /* global Flash */
/* global Api */ import Api from './api';
(function() { (function() {
this.Search = (function() { this.Search = (function() {
......
...@@ -38,7 +38,7 @@ import './shortcuts_navigation'; ...@@ -38,7 +38,7 @@ import './shortcuts_navigation';
} }
ShortcutsIssuable.prototype.replyWithSelectedText = function() { ShortcutsIssuable.prototype.replyWithSelectedText = function() {
var quote, documentFragment, selected, separator; var quote, documentFragment, el, selected, separator;
var replyField = $('.js-main-target-form #note_note'); var replyField = $('.js-main-target-form #note_note');
documentFragment = window.gl.utils.getSelectedFragment(); documentFragment = window.gl.utils.getSelectedFragment();
...@@ -47,10 +47,8 @@ import './shortcuts_navigation'; ...@@ -47,10 +47,8 @@ import './shortcuts_navigation';
return; return;
} }
// If the documentFragment contains more than just Markdown, don't copy as GFM. el = window.gl.CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true));
if (documentFragment.querySelector('.md, .wiki')) return; selected = window.gl.CopyAsGFM.nodeToGFM(el);
selected = window.gl.CopyAsGFM.nodeToGFM(documentFragment);
if (selected.trim() === "") { if (selected.trim() === "") {
return; return;
......
...@@ -67,10 +67,11 @@ export default { ...@@ -67,10 +67,11 @@ export default {
<div> <div>
<assignee-title <assignee-title
:number-of-assignees="store.assignees.length" :number-of-assignees="store.assignees.length"
:loading="loading" :loading="loading || store.isFetching.assignees"
:editable="store.editable" :editable="store.editable"
/> />
<assignees <assignees
v-if="!store.isFetching.assignees"
class="value" class="value"
:root-path="store.rootPath" :root-path="store.rootPath"
:users="store.assignees" :users="store.assignees"
......
...@@ -10,6 +10,9 @@ export default class SidebarStore { ...@@ -10,6 +10,9 @@ export default class SidebarStore {
this.humanTimeEstimate = ''; this.humanTimeEstimate = '';
this.humanTimeSpent = ''; this.humanTimeSpent = '';
this.assignees = []; this.assignees = [];
this.isFetching = {
assignees: true,
};
SidebarStore.singleton = this; SidebarStore.singleton = this;
} }
...@@ -18,6 +21,7 @@ export default class SidebarStore { ...@@ -18,6 +21,7 @@ export default class SidebarStore {
} }
setAssigneeData(data) { setAssigneeData(data) {
this.isFetching.assignees = false;
if (data.assignees) { if (data.assignees) {
this.assignees = data.assignees; this.assignees = data.assignees;
} }
......
/* eslint-disable comma-dangle, max-len, no-useless-return, no-param-reassign, max-len */ /* eslint-disable comma-dangle, max-len, no-useless-return, no-param-reassign, max-len */
/* global Api */ import Api from '../api';
import TemplateSelector from '../blob/template_selector'; import TemplateSelector from '../blob/template_selector';
......
...@@ -451,6 +451,15 @@ function UsersSelect(currentUser, els) { ...@@ -451,6 +451,15 @@ function UsersSelect(currentUser, els) {
selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val(); selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val();
return assignTo(selected); return assignTo(selected);
} }
// Automatically close dropdown after assignee is selected
// since CE has no multiple assignees
// EE does not have a max-select
if ($dropdown.data('max-select') &&
getSelected().length === $dropdown.data('max-select')) {
// Close the dropdown
$dropdown.dropdown('toggle');
}
}, },
id: function (user) { id: function (user) {
return user.id; return user.id;
......
...@@ -56,7 +56,7 @@ export default { ...@@ -56,7 +56,7 @@ export default {
<div class="ci-widget"> <div class="ci-widget">
<div class="ci-status-icon ci-status-icon-success"> <div class="ci-status-icon ci-status-icon-success">
<span class="js-icon-link icon-link"> <span class="js-icon-link icon-link">
<span <span class="ci-status-icon"
v-html="svg" v-html="svg"
aria-hidden="true"></span> aria-hidden="true"></span>
</span> </span>
......
...@@ -92,10 +92,7 @@ export default { ...@@ -92,10 +92,7 @@ export default {
:class="{'label-truncated has-tooltip': isBranchTitleLong(mr.targetBranch)}" :class="{'label-truncated has-tooltip': isBranchTitleLong(mr.targetBranch)}"
:title="isBranchTitleLong(mr.targetBranch) ? mr.targetBranch : ''" :title="isBranchTitleLong(mr.targetBranch) ? mr.targetBranch : ''"
data-placement="bottom"> data-placement="bottom">
<a <a :href="mr.targetBranchPath">{{mr.targetBranch}}</a>
:href="mr.targetBranchPath">
{{mr.targetBranch}}
</a>
</span> </span>
</strong> </strong>
<span <span
......
import eventHub from '../../event_hub';
export default { export default {
name: 'MRWidgetAutoMergeFailed', name: 'MRWidgetAutoMergeFailed',
props: { props: {
mr: { type: Object, required: true }, mr: { type: Object, required: true },
}, },
data() {
return {
isRefreshing: false,
};
},
methods: {
refreshWidget() {
this.isRefreshing = true;
eventHub.$emit('MRWidgetUpdateRequested', () => {
this.isRefreshing = false;
});
},
},
template: ` template: `
<div class="mr-widget-body"> <div class="mr-widget-body">
<button <button
...@@ -13,8 +28,19 @@ export default { ...@@ -13,8 +28,19 @@ export default {
</button> </button>
<span class="bold danger"> <span class="bold danger">
This merge request failed to be merged automatically. This merge request failed to be merged automatically.
<button
@click="refreshWidget"
:class="{ disabled: isRefreshing }"
type="button"
class="btn btn-xs btn-default">
<i
v-if="isRefreshing"
class="fa fa-spinner fa-spin"
aria-hidden="true" />
Refresh
</button>
</span> </span>
<div class="merge-error-text"> <div class="merge-error-text danger bold">
{{mr.mergeError}} {{mr.mergeError}}
</div> </div>
</div> </div>
......
...@@ -33,7 +33,7 @@ export default { ...@@ -33,7 +33,7 @@ export default {
return this.useCommitMessageWithDescription ? withoutDesc : withDesc; return this.useCommitMessageWithDescription ? withoutDesc : withDesc;
}, },
mergeButtonClass() { mergeButtonClass() {
const defaultClass = 'btn btn-success accept-merge-request'; const defaultClass = 'btn btn-small btn-success accept-merge-request';
const failedClass = `${defaultClass} btn-danger`; const failedClass = `${defaultClass} btn-danger`;
const inActionClass = `${defaultClass} btn-info`; const inActionClass = `${defaultClass} btn-info`;
const { pipeline, isPipelineActive, isPipelineFailed, hasCI, ciStatus } = this.mr; const { pipeline, isPipelineActive, isPipelineFailed, hasCI, ciStatus } = this.mr;
...@@ -214,7 +214,7 @@ export default { ...@@ -214,7 +214,7 @@ export default {
v-if="shouldShowMergeOptionsDropdown" v-if="shouldShowMergeOptionsDropdown"
:disabled="isMergeButtonDisabled" :disabled="isMergeButtonDisabled"
type="button" type="button"
class="btn btn-info dropdown-toggle" class="btn btn-small btn-info dropdown-toggle"
data-toggle="dropdown"> data-toggle="dropdown">
<i <i
class="fa fa-caret-down" class="fa fa-caret-down"
......
...@@ -11,10 +11,6 @@ export default function deviseState(data) { ...@@ -11,10 +11,6 @@ export default function deviseState(data) {
return 'conflicts'; return 'conflicts';
} else if (data.work_in_progress) { } else if (data.work_in_progress) {
return 'workInProgress'; return 'workInProgress';
} else if (this.mergeWhenPipelineSucceeds) {
return this.mergeError ? 'autoMergeFailed' : 'mergeWhenPipelineSucceeds';
} else if (!this.canMerge) {
return 'notAllowedToMerge';
} else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) { } else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) {
return 'pipelineFailed'; return 'pipelineFailed';
} else if (this.hasMergeableDiscussionsState) { } else if (this.hasMergeableDiscussionsState) {
...@@ -23,6 +19,10 @@ export default function deviseState(data) { ...@@ -23,6 +19,10 @@ export default function deviseState(data) {
return 'pipelineBlocked'; return 'pipelineBlocked';
} else if (this.hasSHAChanged) { } else if (this.hasSHAChanged) {
return 'shaMismatch'; return 'shaMismatch';
} else if (this.mergeWhenPipelineSucceeds) {
return this.mergeError ? 'autoMergeFailed' : 'mergeWhenPipelineSucceeds';
} else if (!this.canMerge) {
return 'notAllowedToMerge';
} else if (this.canBeMerged) { } else if (this.canBeMerged) {
return 'readyToMerge'; return 'readyToMerge';
} }
......
...@@ -4,7 +4,7 @@ import { getStateKey } from '../dependencies'; ...@@ -4,7 +4,7 @@ import { getStateKey } from '../dependencies';
export default class MergeRequestStore { export default class MergeRequestStore {
constructor(data) { constructor(data) {
this.startingSha = data.diff_head_sha; this.sha = data.diff_head_sha;
this.setData(data); this.setData(data);
} }
...@@ -19,7 +19,6 @@ export default class MergeRequestStore { ...@@ -19,7 +19,6 @@ export default class MergeRequestStore {
this.targetBranch = data.target_branch; this.targetBranch = data.target_branch;
this.sourceBranch = data.source_branch; this.sourceBranch = data.source_branch;
this.mergeStatus = data.merge_status; this.mergeStatus = data.merge_status;
this.sha = data.diff_head_sha;
this.commitMessage = data.merge_commit_message; this.commitMessage = data.merge_commit_message;
this.commitMessageWithDescription = data.merge_commit_message_with_description; this.commitMessageWithDescription = data.merge_commit_message_with_description;
this.commitsCount = data.commits_count; this.commitsCount = data.commits_count;
...@@ -72,7 +71,7 @@ export default class MergeRequestStore { ...@@ -72,7 +71,7 @@ export default class MergeRequestStore {
this.canMerge = !!data.merge_path; this.canMerge = !!data.merge_path;
this.canCreateIssue = currentUser.can_create_issue || false; this.canCreateIssue = currentUser.can_create_issue || false;
this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path; this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path;
this.hasSHAChanged = this.sha !== this.startingSha; this.hasSHAChanged = this.sha !== data.diff_head_sha;
this.canBeMerged = data.can_be_merged || false; this.canBeMerged = data.can_be_merged || false;
// Cherry-pick and Revert actions related // Cherry-pick and Revert actions related
......
import commitIconSvg from 'icons/_icon_commit.svg'; import commitIconSvg from 'icons/_icon_commit.svg';
import userAvatarLink from './user_avatar/user_avatar_link.vue';
export default { export default {
props: { props: {
...@@ -110,6 +111,9 @@ export default { ...@@ -110,6 +111,9 @@ export default {
return { commitIconSvg }; return { commitIconSvg };
}, },
components: {
userAvatarLink,
},
template: ` template: `
<div class="branch-commit"> <div class="branch-commit">
...@@ -133,16 +137,14 @@ export default { ...@@ -133,16 +137,14 @@ export default {
<p class="commit-title"> <p class="commit-title">
<span v-if="title"> <span v-if="title">
<a v-if="hasAuthor" <user-avatar-link
v-if="hasAuthor"
class="avatar-image-container" class="avatar-image-container"
:href="author.web_url"> :link-href="author.web_url"
<img :img-src="author.avatar_url"
class="avatar has-tooltip s20" :img-alt="userImageAltDescription"
:src="author.avatar_url" :tooltip-text="author.username"
:alt="userImageAltDescription" />
:title="author.username" />
</a>
<a class="commit-row-message" <a class="commit-row-message"
:href="commitUrl"> :href="commitUrl">
{{title}} {{title}}
......
<script>
/* This is a re-usable vue component for rendering a user avatar that
does not need to link to the user's profile. The image and an optional
tooltip can be configured by props passed to this component.
Sample configuration:
<user-avatar-image
:img-src="userAvatarSrc"
:img-alt="tooltipText"
:tooltip-text="tooltipText"
tooltip-placement="top"
/>
*/
import defaultAvatarUrl from 'images/no_avatar.png';
import TooltipMixin from '../../mixins/tooltip';
export default {
name: 'UserAvatarImage',
mixins: [TooltipMixin],
props: {
imgSrc: {
type: String,
required: false,
default: defaultAvatarUrl,
},
cssClasses: {
type: String,
required: false,
default: '',
},
imgAlt: {
type: String,
required: false,
default: 'user avatar',
},
size: {
type: Number,
required: false,
default: 20,
},
tooltipText: {
type: String,
required: false,
default: '',
},
tooltipPlacement: {
type: String,
required: false,
default: 'top',
},
},
computed: {
tooltipContainer() {
return this.tooltipText ? 'body' : null;
},
avatarSizeClass() {
return `s${this.size}`;
},
},
};
</script>
<template>
<img
class="avatar"
:class="[avatarSizeClass, cssClasses]"
:src="imgSrc"
:width="size"
:height="size"
:alt="imgAlt"
:data-container="tooltipContainer"
:data-placement="tooltipPlacement"
:title="tooltipText"
ref="tooltip"
/>
</template>
<script>
/* This is a re-usable vue component for rendering a user avatar wrapped in
a clickable link (likely to the user's profile). The link, image, and
tooltip can be configured by props passed to this component.
Sample configuration:
<user-avatar-link
:link-href="userProfileUrl"
:img-src="userAvatarSrc"
:img-alt="tooltipText"
:img-size="20"
:tooltip-text="tooltipText"
tooltip-placement="top"
/>
*/
import userAvatarImage from './user_avatar_image.vue';
export default {
name: 'UserAvatarLink',
components: {
userAvatarImage,
},
props: {
linkHref: {
type: String,
required: false,
default: '',
},
imgSrc: {
type: String,
required: false,
default: '',
},
imgAlt: {
type: String,
required: false,
default: '',
},
imgCssClasses: {
type: String,
required: false,
default: '',
},
imgSize: {
type: Number,
required: false,
default: 20,
},
tooltipText: {
type: String,
required: false,
default: '',
},
tooltipPlacement: {
type: String,
required: false,
default: 'top',
},
},
};
</script>
<template>
<a
class="user-avatar-link"
:href="linkHref">
<user-avatar-image
:img-src="imgSrc"
:img-alt="imgAlt"
:css-classes="imgCssClasses"
:size="imgSize"
:tooltip-text="tooltipText"
:tooltip-placement="tooltipPlacement"
/>
</a>
</template>
<script>
/* This is a re-usable vue component for rendering a user avatar svg (typically
for a blank state). It will receive styles comparable to the user avatar,
but no image is loaded, it isn't wrapped in a link, and tooltips aren't supported.
The svg and avatar size can be configured by props passed to this component.
Sample configuration:
<user-avatar-svg
:svg="potentialApproverSvg"
:size="20"
/>
*/
export default {
props: {
svg: {
type: String,
required: true,
},
size: {
type: Number,
required: false,
default: 20,
},
},
computed: {
avatarSizeClass() {
return `s${this.size}`;
},
},
};
</script>
<template>
<svg
:class="avatarSizeClass"
:height="size"
:width="size"
v-html="svg">
</svg>
</template>
...@@ -4,7 +4,7 @@ import VueResource from 'vue-resource'; ...@@ -4,7 +4,7 @@ import VueResource from 'vue-resource';
Vue.use(VueResource); Vue.use(VueResource);
// Maintain a global counter for active requests // Maintain a global counter for active requests
// see: spec/support/wait_for_vue_resource.rb // see: spec/support/wait_for_requests.rb
Vue.http.interceptors.push((request, next) => { Vue.http.interceptors.push((request, next) => {
window.activeVueResources = window.activeVueResources || 0; window.activeVueResources = window.activeVueResources || 0;
window.activeVueResources += 1; window.activeVueResources += 1;
......
...@@ -10,6 +10,8 @@ ...@@ -10,6 +10,8 @@
border-radius: $avatar_radius; border-radius: $avatar_radius;
border: 1px solid $avatar-border; border: 1px solid $avatar-border;
&.s16 { @include avatar-size(16px, 6px); } &.s16 { @include avatar-size(16px, 6px); }
&.s18 { @include avatar-size(18px, 6px); }
&.s19 { @include avatar-size(19px, 6px); }
&.s20 { @include avatar-size(20px, 7px); } &.s20 { @include avatar-size(20px, 7px); }
&.s24 { @include avatar-size(24px, 8px); } &.s24 { @include avatar-size(24px, 8px); }
&.s26 { @include avatar-size(26px, 8px); } &.s26 { @include avatar-size(26px, 8px); }
......
...@@ -108,8 +108,9 @@ ...@@ -108,8 +108,9 @@
} }
.award-control { .award-control {
margin-right: 5px; margin: 0 5px 6px 0;
outline: 0; outline: 0;
position: relative;
&.disabled { &.disabled {
cursor: default; cursor: default;
...@@ -227,8 +228,8 @@ ...@@ -227,8 +228,8 @@
.award-control-icon-positive, .award-control-icon-positive,
.award-control-icon-super-positive { .award-control-icon-super-positive {
position: absolute; position: absolute;
left: 11px; left: 10px;
bottom: 7px; bottom: 6px;
opacity: 0; opacity: 0;
@include transition(opacity, transform); @include transition(opacity, transform);
} }
...@@ -237,7 +238,3 @@ ...@@ -237,7 +238,3 @@
vertical-align: middle; vertical-align: middle;
} }
} }
.note-awards .award-control-icon-positive {
left: 6px;
}
...@@ -263,7 +263,9 @@ ...@@ -263,7 +263,9 @@
} }
.filtered-search-input-dropdown-menu { .filtered-search-input-dropdown-menu {
max-height: 215px;
max-width: 280px; max-width: 280px;
overflow: auto;
@media (max-width: $screen-xs-min) { @media (max-width: $screen-xs-min) {
width: auto; width: auto;
...@@ -372,11 +374,6 @@ ...@@ -372,11 +374,6 @@
padding: 0; padding: 0;
} }
.filter-dropdown {
max-height: 215px;
overflow: auto;
}
@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
.issue-bulk-update-dropdown-toggle { .issue-bulk-update-dropdown-toggle {
width: 100px; width: 100px;
......
...@@ -31,7 +31,6 @@ header { ...@@ -31,7 +31,6 @@ header {
border: none; border: none;
border-bottom: 1px solid $border-color; border-bottom: 1px solid $border-color;
position: fixed; position: fixed;
z-index: 300;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
...@@ -41,7 +40,17 @@ header { ...@@ -41,7 +40,17 @@ header {
} }
&.with-horizontal-nav { &.with-horizontal-nav {
border-color: transparent; border-bottom: 0;
.navbar-border {
height: 1px;
position: absolute;
right: 0;
left: 0;
bottom: -1px;
background-color: $border-color;
opacity: 0;
}
} }
.container-fluid { .container-fluid {
...@@ -115,16 +124,6 @@ header { ...@@ -115,16 +124,6 @@ header {
} }
} }
.navbar-border {
height: 1px;
position: absolute;
right: 0;
left: 0;
bottom: 0;
background-color: $border-color;
opacity: 0;
}
.global-dropdown { .global-dropdown {
position: absolute; position: absolute;
left: -10px; left: -10px;
......
...@@ -65,3 +65,7 @@ ...@@ -65,3 +65,7 @@
text-decoration: none; text-decoration: none;
} }
} }
.user-avatar-link {
text-decoration: none;
}
...@@ -470,8 +470,8 @@ ...@@ -470,8 +470,8 @@
} }
} }
.activities { .nav-block {
.nav-block { &.activities {
border-bottom: 1px solid $border-color; border-bottom: 1px solid $border-color;
.nav-links { .nav-links {
......
...@@ -83,4 +83,8 @@ ...@@ -83,4 +83,8 @@
position: fixed; position: fixed;
top: $header-height; top: $header-height;
} }
&:not(.affix-top) {
min-height: 100%;
}
} }
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
.note-text { .note-text {
p:last-child { p:last-child {
margin-bottom: 0; margin-bottom: 0 !important;
} }
} }
...@@ -23,7 +23,6 @@ ...@@ -23,7 +23,6 @@
} }
.timeline-entry { .timeline-entry {
padding: $gl-padding $gl-btn-padding 0;
border-color: $white-normal; border-color: $white-normal;
color: $gl-text-color; color: $gl-text-color;
border-bottom: 1px solid $border-white-light; border-bottom: 1px solid $border-white-light;
......
...@@ -139,6 +139,15 @@ ...@@ -139,6 +139,15 @@
line-height: 1.6em; line-height: 1.6em;
overflow-x: auto; overflow-x: auto;
border-radius: 2px; border-radius: 2px;
&.plain-readme {
background: none;
border: none;
padding: 0;
margin: 0;
font-size: 14px;
}
} }
p > code { p > code {
...@@ -169,14 +178,14 @@ ...@@ -169,14 +178,14 @@
} }
ul.task-list { ul.task-list {
li.task-list-item { > li.task-list-item {
list-style-type: none; list-style-type: none;
position: relative; position: relative;
min-height: 22px; min-height: 22px;
padding-left: 28px; padding-left: 28px;
margin-left: 0 !important; margin-left: 0 !important;
input.task-list-item-checkbox { > input.task-list-item-checkbox {
position: absolute; position: absolute;
left: 8px; left: 8px;
top: 5px; top: 5px;
...@@ -279,14 +288,6 @@ h6 { ...@@ -279,14 +288,6 @@ h6 {
/** CODE **/ /** CODE **/
pre { pre {
font-family: $monospace_font; font-family: $monospace_font;
&.plain-readme {
background: none;
border: none;
padding: 0;
margin: 0;
font-size: 14px;
}
} }
code { code {
......
...@@ -573,3 +573,12 @@ $filter-value-selected-color: #d7d7d7; ...@@ -573,3 +573,12 @@ $filter-value-selected-color: #d7d7d7;
Animation Functions Animation Functions
*/ */
$dropdown-animation-timing: cubic-bezier(0.23, 1, 0.32, 1); $dropdown-animation-timing: cubic-bezier(0.23, 1, 0.32, 1);
/*
GitLab Plans
*/
$gl-gold-plan: #d4af37;
$gl-silver-plan: #91a1ab;
$gl-bronze-plan: #cd7f32;
$gl-no-plan: $gl-gray-light;
...@@ -669,6 +669,9 @@ ...@@ -669,6 +669,9 @@
.dropdown-menu { .dropdown-menu {
position: fixed; position: fixed;
left: 10px;
top: 175px;
min-width: 200px;
} }
} }
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment