Commit 99b142bc authored by Lin Jen-Shin's avatar Lin Jen-Shin

Merge remote-tracking branch 'upstream/master' into 25680-CI_ENVIRONMENT_URL

* upstream/master: (251 commits)
  Don't match email addresses or foo@bar as user references
  Revert "Update GITLAB_SHELL_VERSION"
  Update GITLAB_SHELL_VERSION
  Add feature toggles through Flipper
  Change no_limits to limits
  Move includes call to scope
  Add GitLab Resources to University
  Add Documentation for GIT_CHECKOUT variable
  Remove entry variable
  Do not try to preload Commits when using Note.includes(:noteable)
  Ui improvements for count badges and permission badges
  Rename the other jobs
  Update jobs_spec for changes from builds_spec
  Introduce source to pipeline entity
  Update docs related to protected actions
  Add changelog for protected branches abilities fix
  Ask for an example project for bug reports
  Center loading spinner in issuable filters
  Fix chat commands specs related to protected actions
  Fix builds controller specs related to protected actions
  ...
parents 9e60d57d f74f0338
---
engines:
brakeman:
enabled: true
bundler-audit:
enabled: true
duplication:
enabled: true
config:
languages:
- ruby
- javascript
eslint:
enabled: true
fixme:
enabled: true
rubocop:
enabled: true
ratings:
paths:
- Gemfile.lock
- "**.erb"
- "**.haml"
- "**.rb"
- "**.rhtml"
- "**.slim"
- "**.inc"
- "**.js"
- "**.jsx"
- "**.module"
exclude_paths:
- config/
- db/
- features/
- node_modules/
- spec/
- vendor/
- lib/api/v3/
...@@ -20,6 +20,12 @@ Please remove this notice if you're confident your issue isn't a duplicate. ...@@ -20,6 +20,12 @@ Please remove this notice if you're confident your issue isn't a duplicate.
(How one can reproduce the issue - this is very important) (How one can reproduce the issue - this is very important)
### Example Project
(If possible, please create an example project here on GitLab.com that exhibits the problematic behaviour, and link to it here in the bug report)
(If you are using an older version of GitLab, this will also determine whether the bug has been fixed in a more recent version)
### What is the current *bug* behavior? ### What is the current *bug* behavior?
(What actually happens) (What actually happens)
......
...@@ -57,7 +57,7 @@ linters: ...@@ -57,7 +57,7 @@ linters:
# Reports when you define the same property twice in a single rule set. # Reports when you define the same property twice in a single rule set.
DuplicateProperty: DuplicateProperty:
enabled: false enabled: true
# Separate rule, function, and mixin declarations with empty lines. # Separate rule, function, and mixin declarations with empty lines.
EmptyLineBetweenBlocks: EmptyLineBetweenBlocks:
......
...@@ -109,7 +109,7 @@ gem 'seed-fu', '~> 2.3.5' ...@@ -109,7 +109,7 @@ gem 'seed-fu', '~> 2.3.5'
# Markdown and HTML processing # Markdown and HTML processing
gem 'html-pipeline', '~> 1.11.0' gem 'html-pipeline', '~> 1.11.0'
gem 'deckar01-task_list', '1.0.6', require: 'task_list/railtie' gem 'deckar01-task_list', '2.0.0'
gem 'gitlab-markup', '~> 1.5.1' gem 'gitlab-markup', '~> 1.5.1'
gem 'redcarpet', '~> 3.4' gem 'redcarpet', '~> 3.4'
gem 'RedCloth', '~> 4.3.2' gem 'RedCloth', '~> 4.3.2'
...@@ -370,3 +370,7 @@ gem 'sys-filesystem', '~> 1.1.6' ...@@ -370,3 +370,7 @@ gem 'sys-filesystem', '~> 1.1.6'
gem 'gitaly', '~> 0.7.0' gem 'gitaly', '~> 0.7.0'
gem 'toml-rb', '~> 0.3.15', require: false gem 'toml-rb', '~> 0.3.15', require: false
# Feature toggles
gem 'flipper', '~> 0.10.2'
gem 'flipper-active_record', '~> 0.10.2'
...@@ -141,10 +141,8 @@ GEM ...@@ -141,10 +141,8 @@ GEM
database_cleaner (1.5.3) database_cleaner (1.5.3)
debug_inspector (0.0.2) debug_inspector (0.0.2)
debugger-ruby_core_source (1.3.8) debugger-ruby_core_source (1.3.8)
deckar01-task_list (1.0.6) deckar01-task_list (2.0.0)
activesupport (~> 4.0)
html-pipeline html-pipeline
rack (~> 1.0)
default_value_for (3.0.2) default_value_for (3.0.2)
activerecord (>= 3.2.0, < 5.1) activerecord (>= 3.2.0, < 5.1)
descendants_tracker (0.0.4) descendants_tracker (0.0.4)
...@@ -208,6 +206,10 @@ GEM ...@@ -208,6 +206,10 @@ GEM
path_expander (~> 1.0) path_expander (~> 1.0)
ruby_parser (~> 3.0) ruby_parser (~> 3.0)
sexp_processor (~> 4.0) sexp_processor (~> 4.0)
flipper (0.10.2)
flipper-active_record (0.10.2)
activerecord (>= 3.2, < 6)
flipper (~> 0.10.2)
flowdock (0.7.1) flowdock (0.7.1)
httparty (~> 0.7) httparty (~> 0.7)
multi_json multi_json
...@@ -341,7 +343,7 @@ GEM ...@@ -341,7 +343,7 @@ GEM
grape-entity (0.6.0) grape-entity (0.6.0)
activesupport activesupport
multi_json (>= 1.3.2) multi_json (>= 1.3.2)
grpc (1.3.4) grpc (1.2.5)
google-protobuf (~> 3.1) google-protobuf (~> 3.1)
googleauth (~> 0.5.1) googleauth (~> 0.5.1)
haml (4.0.7) haml (4.0.7)
...@@ -499,11 +501,10 @@ GEM ...@@ -499,11 +501,10 @@ GEM
omniauth (~> 1.0) omniauth (~> 1.0)
omniauth-oauth2 (~> 1.0) omniauth-oauth2 (~> 1.0)
omniauth-google-oauth2 (0.4.1) omniauth-google-oauth2 (0.4.1)
addressable (~> 2.3) jwt (~> 1.5.2)
jwt (~> 1.0)
multi_json (~> 1.3) multi_json (~> 1.3)
omniauth (>= 1.1.1) omniauth (>= 1.1.1)
omniauth-oauth2 (~> 1.3.1) omniauth-oauth2 (>= 1.3.1)
omniauth-kerberos (0.3.0) omniauth-kerberos (0.3.0)
omniauth-multipassword omniauth-multipassword
timfel-krb5-auth (~> 0.8) timfel-krb5-auth (~> 0.8)
...@@ -896,7 +897,7 @@ DEPENDENCIES ...@@ -896,7 +897,7 @@ DEPENDENCIES
creole (~> 0.5.0) creole (~> 0.5.0)
d3_rails (~> 3.5.0) d3_rails (~> 3.5.0)
database_cleaner (~> 1.5.0) database_cleaner (~> 1.5.0)
deckar01-task_list (= 1.0.6) deckar01-task_list (= 2.0.0)
default_value_for (~> 3.0.0) default_value_for (~> 3.0.0)
devise (~> 4.2) devise (~> 4.2)
devise-two-factor (~> 3.0.0) devise-two-factor (~> 3.0.0)
...@@ -910,6 +911,8 @@ DEPENDENCIES ...@@ -910,6 +911,8 @@ DEPENDENCIES
faraday (~> 0.11.0) faraday (~> 0.11.0)
ffaker (~> 2.4) ffaker (~> 2.4)
flay (~> 2.8.0) flay (~> 2.8.0)
flipper (~> 0.10.2)
flipper-active_record (~> 0.10.2)
fog-aws (~> 0.9) fog-aws (~> 0.9)
fog-core (~> 1.44) fog-core (~> 1.44)
fog-google (~> 0.5) fog-google (~> 0.5)
...@@ -1060,4 +1063,4 @@ DEPENDENCIES ...@@ -1060,4 +1063,4 @@ DEPENDENCIES
wikicloth (= 0.8.1) wikicloth (= 0.8.1)
BUNDLED WITH BUNDLED WITH
1.14.6 1.15.0
...@@ -111,7 +111,7 @@ export default class BlobViewer { ...@@ -111,7 +111,7 @@ export default class BlobViewer {
BlobViewer.loadViewer(newViewer) BlobViewer.loadViewer(newViewer)
.then((viewer) => { .then((viewer) => {
$(viewer).syntaxHighlight(); $(viewer).renderGFM();
this.$fileHolder.trigger('highlight:line'); this.$fileHolder.trigger('highlight:line');
gl.utils.handleLocationHash(); gl.utils.handleLocationHash();
......
...@@ -70,6 +70,7 @@ $(() => { ...@@ -70,6 +70,7 @@ $(() => {
gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId); gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId);
this.filterManager = new FilteredSearchBoards(Store.filter, true); this.filterManager = new FilteredSearchBoards(Store.filter, true);
this.filterManager.setup();
// Listen for updateTokens event // Listen for updateTokens event
eventHub.$on('updateTokens', this.updateTokens); eventHub.$on('updateTokens', this.updateTokens);
......
...@@ -13,6 +13,7 @@ export default { ...@@ -13,6 +13,7 @@ export default {
FilteredSearchContainer.container = this.$el; FilteredSearchContainer.container = this.$el;
this.filteredSearch = new FilteredSearchBoards(this.store); this.filteredSearch = new FilteredSearchBoards(this.store);
this.filteredSearch.setup();
this.filteredSearch.removeTokens(); this.filteredSearch.removeTokens();
this.filteredSearch.handleInputPlaceholder(); this.filteredSearch.handleInputPlaceholder();
this.filteredSearch.toggleClearSearchButton(); this.filteredSearch.toggleClearSearchButton();
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import FilteredSearchContainer from '../filtered_search/container'; import FilteredSearchContainer from '../filtered_search/container';
export default class FilteredSearchBoards extends gl.FilteredSearchManager { export default class FilteredSearchBoards extends gl.FilteredSearchManager {
constructor(store, updateUrl = false) { constructor(store, updateUrl = false, cantEdit = []) {
super('boards'); super('boards');
this.store = store; this.store = store;
...@@ -11,6 +11,8 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager { ...@@ -11,6 +11,8 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager {
// Issue boards is slightly different, we handle all the requests async // Issue boards is slightly different, we handle all the requests async
// instead or reloading the page, we just re-fire the list ajax requests // instead or reloading the page, we just re-fire the list ajax requests
this.isHandledAsync = true; this.isHandledAsync = true;
this.cantEdit = cantEdit;
} }
updateObject(path) { updateObject(path) {
...@@ -40,4 +42,8 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager { ...@@ -40,4 +42,8 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager {
// Get the placeholder back if search is empty // Get the placeholder back if search is empty
this.filteredSearchInput.dispatchEvent(new Event('input')); this.filteredSearchInput.dispatchEvent(new Event('input'));
} }
canEdit(tokenName) {
return this.cantEdit.indexOf(tokenName) === -1;
}
} }
This diff is collapsed.
...@@ -118,13 +118,14 @@ import ShortcutsBlob from './shortcuts_blob'; ...@@ -118,13 +118,14 @@ import ShortcutsBlob from './shortcuts_blob';
shortcut_handler = new ShortcutsNavigation(); shortcut_handler = new ShortcutsNavigation();
new UsersSelect(); new UsersSelect();
break; break;
case 'projects:builds:show': case 'projects:jobs:show':
new Build(); new Build();
break; break;
case 'projects:merge_requests:index': case 'projects:merge_requests:index':
case 'projects:issues:index': case 'projects:issues:index':
if (gl.FilteredSearchManager && document.querySelector('.filtered-search')) { if (gl.FilteredSearchManager && document.querySelector('.filtered-search')) {
new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests'); const filteredSearchManager = new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests');
filteredSearchManager.setup();
} }
Issuable.init(); Issuable.init();
new gl.IssuableBulkActions({ new gl.IssuableBulkActions({
......
...@@ -194,6 +194,7 @@ window.DropzoneInput = (function() { ...@@ -194,6 +194,7 @@ window.DropzoneInput = (function() {
$(child).val(beforeSelection + formattedText + afterSelection); $(child).val(beforeSelection + formattedText + afterSelection);
textarea.setSelectionRange(caretStart + formattedText.length, caretEnd + formattedText.length); textarea.setSelectionRange(caretStart + formattedText.length, caretEnd + formattedText.length);
textarea.style.height = `${textarea.scrollHeight}px`; textarea.style.height = `${textarea.scrollHeight}px`;
formTextarea.get(0).dispatchEvent(new Event('input'));
return formTextarea.trigger('input'); return formTextarea.trigger('input');
}; };
......
...@@ -2,9 +2,9 @@ import './dropdown_hint'; ...@@ -2,9 +2,9 @@ import './dropdown_hint';
import './dropdown_non_user'; import './dropdown_non_user';
import './dropdown_user'; import './dropdown_user';
import './dropdown_utils'; import './dropdown_utils';
import './filtered_search_token_keys';
import './filtered_search_dropdown_manager'; import './filtered_search_dropdown_manager';
import './filtered_search_dropdown'; import './filtered_search_dropdown';
import './filtered_search_manager'; import './filtered_search_manager';
import './filtered_search_token_keys';
import './filtered_search_tokenizer'; import './filtered_search_tokenizer';
import './filtered_search_visual_tokens'; import './filtered_search_visual_tokens';
...@@ -6,6 +6,7 @@ import eventHub from './event_hub'; ...@@ -6,6 +6,7 @@ import eventHub from './event_hub';
class FilteredSearchManager { class FilteredSearchManager {
constructor(page) { constructor(page) {
this.page = page;
this.container = FilteredSearchContainer.container; this.container = FilteredSearchContainer.container;
this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.filteredSearchInputForm = this.filteredSearchInput.form; this.filteredSearchInputForm = this.filteredSearchInput.form;
...@@ -17,16 +18,18 @@ class FilteredSearchManager { ...@@ -17,16 +18,18 @@ class FilteredSearchManager {
isLocalStorageAvailable: RecentSearchesService.isAvailable(), isLocalStorageAvailable: RecentSearchesService.isAvailable(),
allowedKeys: this.filteredSearchTokenKeys.getKeys(), allowedKeys: this.filteredSearchTokenKeys.getKeys(),
}); });
const searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown'); this.searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown');
const projectPath = searchHistoryDropdownElement ? const projectPath = this.searchHistoryDropdownElement ?
searchHistoryDropdownElement.dataset.projectFullPath : 'project'; this.searchHistoryDropdownElement.dataset.projectFullPath : 'project';
let recentSearchesPagePrefix = 'issue-recent-searches'; let recentSearchesPagePrefix = 'issue-recent-searches';
if (page === 'merge_requests') { if (this.page === 'merge_requests') {
recentSearchesPagePrefix = 'merge-request-recent-searches'; recentSearchesPagePrefix = 'merge-request-recent-searches';
} }
const recentSearchesKey = `${projectPath}-${recentSearchesPagePrefix}`; const recentSearchesKey = `${projectPath}-${recentSearchesPagePrefix}`;
this.recentSearchesService = new RecentSearchesService(recentSearchesKey); this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
}
setup() {
// Fetch recent searches from localStorage // Fetch recent searches from localStorage
this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch() this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch()
.catch((error) => { .catch((error) => {
...@@ -47,12 +50,12 @@ class FilteredSearchManager { ...@@ -47,12 +50,12 @@ 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') || '', this.tokenizer, page); this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', this.tokenizer, this.page);
this.recentSearchesRoot = new RecentSearchesRoot( this.recentSearchesRoot = new RecentSearchesRoot(
this.recentSearchesStore, this.recentSearchesStore,
this.recentSearchesService, this.recentSearchesService,
searchHistoryDropdownElement, this.searchHistoryDropdownElement,
); );
this.recentSearchesRoot.init(); this.recentSearchesRoot.init();
...@@ -141,7 +144,9 @@ class FilteredSearchManager { ...@@ -141,7 +144,9 @@ class FilteredSearchManager {
if (e.keyCode === 8 || e.keyCode === 46) { if (e.keyCode === 8 || e.keyCode === 46) {
const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (this.filteredSearchInput.value === '' && lastVisualToken) { const sanitizedTokenName = lastVisualToken && lastVisualToken.querySelector('.name').textContent.trim();
const canEdit = sanitizedTokenName && this.canEdit && this.canEdit(sanitizedTokenName);
if (this.filteredSearchInput.value === '' && lastVisualToken && canEdit) {
this.filteredSearchInput.value = gl.FilteredSearchVisualTokens.getLastTokenPartial(); this.filteredSearchInput.value = gl.FilteredSearchVisualTokens.getLastTokenPartial();
gl.FilteredSearchVisualTokens.removeLastTokenPartial(); gl.FilteredSearchVisualTokens.removeLastTokenPartial();
} }
...@@ -240,8 +245,10 @@ class FilteredSearchManager { ...@@ -240,8 +245,10 @@ class FilteredSearchManager {
editToken(e) { editToken(e) {
const token = e.target.closest('.js-visual-token'); const token = e.target.closest('.js-visual-token');
const sanitizedTokenName = token.querySelector('.name').textContent.trim();
const canEdit = this.canEdit && this.canEdit(sanitizedTokenName);
if (token) { if (token && canEdit) {
gl.FilteredSearchVisualTokens.editToken(token); gl.FilteredSearchVisualTokens.editToken(token);
this.tokenChange(); this.tokenChange();
} }
...@@ -391,7 +398,12 @@ class FilteredSearchManager { ...@@ -391,7 +398,12 @@ class FilteredSearchManager {
if (condition) { if (condition) {
hasFilteredSearch = true; hasFilteredSearch = true;
gl.FilteredSearchVisualTokens.addFilterVisualToken(condition.tokenKey, condition.value); const canEdit = this.canEdit && this.canEdit(condition.tokenKey);
gl.FilteredSearchVisualTokens.addFilterVisualToken(
condition.tokenKey,
condition.value,
canEdit,
);
} else { } else {
// Sanitize value since URL converts spaces into + // Sanitize value since URL converts spaces into +
// Replace before decode so that we know what was originally + versus the encoded + // Replace before decode so that we know what was originally + versus the encoded +
...@@ -410,18 +422,27 @@ class FilteredSearchManager { ...@@ -410,18 +422,27 @@ class FilteredSearchManager {
} }
hasFilteredSearch = true; hasFilteredSearch = true;
gl.FilteredSearchVisualTokens.addFilterVisualToken(sanitizedKey, `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`); const canEdit = this.canEdit && this.canEdit(sanitizedKey);
gl.FilteredSearchVisualTokens.addFilterVisualToken(
sanitizedKey,
`${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`,
canEdit,
);
} else if (!match && keyParam === 'assignee_id') { } else if (!match && keyParam === 'assignee_id') {
const id = parseInt(value, 10); const id = parseInt(value, 10);
if (usernameParams[id]) { if (usernameParams[id]) {
hasFilteredSearch = true; hasFilteredSearch = true;
gl.FilteredSearchVisualTokens.addFilterVisualToken('assignee', `@${usernameParams[id]}`); const tokenName = 'assignee';
const canEdit = this.canEdit && this.canEdit(tokenName);
gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit);
} }
} else if (!match && keyParam === 'author_id') { } else if (!match && keyParam === 'author_id') {
const id = parseInt(value, 10); const id = parseInt(value, 10);
if (usernameParams[id]) { if (usernameParams[id]) {
hasFilteredSearch = true; hasFilteredSearch = true;
gl.FilteredSearchVisualTokens.addFilterVisualToken('author', `@${usernameParams[id]}`); const tokenName = 'author';
const canEdit = this.canEdit && this.canEdit(tokenName);
gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit);
} }
} else if (!match && keyParam === 'search') { } else if (!match && keyParam === 'search') {
hasFilteredSearch = true; hasFilteredSearch = true;
...@@ -516,6 +537,11 @@ class FilteredSearchManager { ...@@ -516,6 +537,11 @@ class FilteredSearchManager {
this.filteredSearchInput.dispatchEvent(new CustomEvent('input')); this.filteredSearchInput.dispatchEvent(new CustomEvent('input'));
this.search(); this.search();
} }
// eslint-disable-next-line class-methods-use-this
canEdit() {
return true;
}
} }
window.gl = window.gl || {}; window.gl = window.gl || {};
......
...@@ -36,15 +36,22 @@ class FilteredSearchVisualTokens { ...@@ -36,15 +36,22 @@ class FilteredSearchVisualTokens {
} }
} }
static createVisualTokenElementHTML() { static createVisualTokenElementHTML(canEdit = true) {
let removeTokenMarkup = '';
if (canEdit) {
removeTokenMarkup = `
<div class="remove-token" role="button">
<i class="fa fa-close"></i>
</div>
`;
}
return ` return `
<div class="selectable" role="button"> <div class="selectable" role="button">
<div class="name"></div> <div class="name"></div>
<div class="value-container"> <div class="value-container">
<div class="value"></div> <div class="value"></div>
<div class="remove-token" role="button"> ${removeTokenMarkup}
<i class="fa fa-close"></i>
</div>
</div> </div>
</div> </div>
`; `;
...@@ -84,13 +91,13 @@ class FilteredSearchVisualTokens { ...@@ -84,13 +91,13 @@ class FilteredSearchVisualTokens {
} }
} }
static addVisualTokenElement(name, value, isSearchTerm) { static addVisualTokenElement(name, value, isSearchTerm, canEdit) {
const li = document.createElement('li'); const li = document.createElement('li');
li.classList.add('js-visual-token'); li.classList.add('js-visual-token');
li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token'); li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token');
if (value) { if (value) {
li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(); li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(canEdit);
FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value); FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value);
} else { } else {
li.innerHTML = '<div class="name"></div>'; li.innerHTML = '<div class="name"></div>';
...@@ -114,20 +121,20 @@ class FilteredSearchVisualTokens { ...@@ -114,20 +121,20 @@ class FilteredSearchVisualTokens {
} }
} }
static addFilterVisualToken(tokenName, tokenValue) { static addFilterVisualToken(tokenName, tokenValue, canEdit) {
const { lastVisualToken, isLastVisualTokenValid } const { lastVisualToken, isLastVisualTokenValid }
= FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const addVisualTokenElement = FilteredSearchVisualTokens.addVisualTokenElement; const addVisualTokenElement = FilteredSearchVisualTokens.addVisualTokenElement;
if (isLastVisualTokenValid) { if (isLastVisualTokenValid) {
addVisualTokenElement(tokenName, tokenValue, false); addVisualTokenElement(tokenName, tokenValue, false, canEdit);
} else { } else {
const previousTokenName = lastVisualToken.querySelector('.name').innerText; const previousTokenName = lastVisualToken.querySelector('.name').innerText;
const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
tokensContainer.removeChild(lastVisualToken); tokensContainer.removeChild(lastVisualToken);
const value = tokenValue || tokenName; const value = tokenValue || tokenName;
addVisualTokenElement(previousTokenName, value, false); addVisualTokenElement(previousTokenName, value, false, canEdit);
} }
} }
......
...@@ -468,8 +468,8 @@ GitLabDropdown = (function() { ...@@ -468,8 +468,8 @@ GitLabDropdown = (function() {
// Process the data to make sure rendered data // Process the data to make sure rendered data
// matches the correct layout // matches the correct layout
if (this.fullData && hasMultiSelect && this.options.processData) {
const inputValue = this.filterInput.val(); const inputValue = this.filterInput.val();
if (this.fullData && hasMultiSelect && this.options.processData && inputValue.length === 0) {
this.options.processData.call(this.options, inputValue, this.filteredFullData(), this.parseData.bind(this)); this.options.processData.call(this.options, inputValue, this.filteredFullData(), this.parseData.bind(this));
} }
...@@ -740,6 +740,12 @@ GitLabDropdown = (function() { ...@@ -740,6 +740,12 @@ GitLabDropdown = (function() {
$input.attr('id', this.options.inputId); $input.attr('id', this.options.inputId);
} }
if (this.options.multiSelect) {
Object.keys(selectedObject).forEach((attribute) => {
$input.attr(`data-${attribute}`, selectedObject[attribute]);
});
}
if (this.options.inputMeta) { if (this.options.inputMeta) {
$input.attr('data-meta', selectedObject[this.options.inputMeta]); $input.attr('data-meta', selectedObject[this.options.inputMeta]);
} }
......
<script> <script>
/* global Flash */
import Visibility from 'visibilityjs'; import Visibility from 'visibilityjs';
import Poll from '../../lib/utils/poll'; import Poll from '../../lib/utils/poll';
import eventHub from '../event_hub';
import Service from '../services/index'; import Service from '../services/index';
import Store from '../stores'; import Store from '../stores';
import titleComponent from './title.vue'; import titleComponent from './title.vue';
import descriptionComponent from './description.vue'; import descriptionComponent from './description.vue';
import formComponent from './form.vue';
import '../../lib/utils/url_utility';
export default { export default {
props: { props: {
...@@ -12,15 +16,27 @@ export default { ...@@ -12,15 +16,27 @@ export default {
required: true, required: true,
type: String, type: String,
}, },
canMove: {
required: true,
type: Boolean,
},
canUpdate: { canUpdate: {
required: true, required: true,
type: Boolean, type: Boolean,
}, },
canDestroy: {
required: true,
type: Boolean,
},
issuableRef: { issuableRef: {
type: String, type: String,
required: true, required: true,
}, },
initialTitle: { initialTitleHtml: {
type: String,
required: true,
},
initialTitleText: {
type: String, type: String,
required: true, required: true,
}, },
...@@ -34,10 +50,40 @@ export default { ...@@ -34,10 +50,40 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
issuableTemplates: {
type: Array,
required: false,
default: () => [],
},
isConfidential: {
type: Boolean,
required: true,
},
markdownPreviewUrl: {
type: String,
required: true,
},
markdownDocs: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
projectNamespace: {
type: String,
required: true,
},
projectsAutocompleteUrl: {
type: String,
required: true,
},
}, },
data() { data() {
const store = new Store({ const store = new Store({
titleHtml: this.initialTitle, titleHtml: this.initialTitleHtml,
titleText: this.initialTitleText,
descriptionHtml: this.initialDescriptionHtml, descriptionHtml: this.initialDescriptionHtml,
descriptionText: this.initialDescriptionText, descriptionText: this.initialDescriptionText,
}); });
...@@ -45,19 +91,97 @@ export default { ...@@ -45,19 +91,97 @@ export default {
return { return {
store, store,
state: store.state, state: store.state,
showForm: false,
}; };
}, },
computed: {
formState() {
return this.store.formState;
},
},
components: { components: {
descriptionComponent, descriptionComponent,
titleComponent, titleComponent,
formComponent,
},
methods: {
openForm() {
if (!this.showForm) {
this.showForm = true;
this.store.setFormState({
title: this.state.titleText,
confidential: this.isConfidential,
description: this.state.descriptionText,
lockedWarningVisible: false,
move_to_project_id: 0,
updateLoading: false,
});
}
},
closeForm() {
this.showForm = false;
},
updateIssuable() {
const canPostUpdate = this.store.formState.move_to_project_id !== 0 ?
confirm('Are you sure you want to move this issue to another project?') : true; // eslint-disable-line no-alert
if (!canPostUpdate) {
this.store.setFormState({
updateLoading: false,
});
return;
}
this.service.updateIssuable(this.store.formState)
.then(res => res.json())
.then((data) => {
if (location.pathname !== data.web_url) {
gl.utils.visitUrl(data.web_url);
} else if (data.confidential !== this.isConfidential) {
gl.utils.visitUrl(location.pathname);
}
return this.service.getData();
})
.then(res => res.json())
.then((data) => {
this.store.updateState(data);
eventHub.$emit('close.form');
})
.catch(() => {
eventHub.$emit('close.form');
return new Flash('Error updating issue');
});
},
deleteIssuable() {
this.service.deleteIssuable()
.then(res => res.json())
.then((data) => {
// Stop the poll so we don't get 404's with the issue not existing
this.poll.stop();
gl.utils.visitUrl(data.web_url);
})
.catch(() => {
eventHub.$emit('close.form');
return new Flash('Error deleting issue');
});
},
}, },
created() { created() {
const resource = new Service(this.endpoint); this.service = new Service(this.endpoint);
const poll = new Poll({ this.poll = new Poll({
resource, resource: this.service,
method: 'getData', method: 'getData',
successCallback: (res) => { successCallback: (res) => {
this.store.updateState(res.json()); const data = res.json();
const shouldUpdate = this.store.stateShouldUpdate(data);
this.store.updateState(data);
if (this.showForm && (shouldUpdate.title || shouldUpdate.description)) {
this.store.formState.lockedWarningVisible = true;
}
}, },
errorCallback(err) { errorCallback(err) {
throw new Error(err); throw new Error(err);
...@@ -65,22 +189,46 @@ export default { ...@@ -65,22 +189,46 @@ export default {
}); });
if (!Visibility.hidden()) { if (!Visibility.hidden()) {
poll.makeRequest(); this.poll.makeRequest();
} }
Visibility.change(() => { Visibility.change(() => {
if (!Visibility.hidden()) { if (!Visibility.hidden()) {
poll.restart(); this.poll.restart();
} else { } else {
poll.stop(); this.poll.stop();
} }
}); });
eventHub.$on('delete.issuable', this.deleteIssuable);
eventHub.$on('update.issuable', this.updateIssuable);
eventHub.$on('close.form', this.closeForm);
eventHub.$on('open.form', this.openForm);
},
beforeDestroy() {
eventHub.$off('delete.issuable', this.deleteIssuable);
eventHub.$off('update.issuable', this.updateIssuable);
eventHub.$off('close.form', this.closeForm);
eventHub.$off('open.form', this.openForm);
}, },
}; };
</script> </script>
<template> <template>
<div> <div>
<form-component
v-if="canUpdate && showForm"
:form-state="formState"
:can-move="canMove"
:can-destroy="canDestroy"
:issuable-templates="issuableTemplates"
:markdown-docs="markdownDocs"
:markdown-preview-url="markdownPreviewUrl"
:project-path="projectPath"
:project-namespace="projectNamespace"
:projects-autocomplete-url="projectsAutocompleteUrl"
/>
<div v-else>
<title-component <title-component
:issuable-ref="issuableRef" :issuable-ref="issuableRef"
:title-html="state.titleHtml" :title-html="state.titleHtml"
...@@ -93,4 +241,5 @@ export default { ...@@ -93,4 +241,5 @@ export default {
:updated-at="state.updatedAt" :updated-at="state.updatedAt"
:task-status="state.taskStatus" /> :task-status="state.taskStatus" />
</div> </div>
</div>
</template> </template>
...@@ -18,11 +18,13 @@ ...@@ -18,11 +18,13 @@
}, },
updatedAt: { updatedAt: {
type: String, type: String,
required: true, required: false,
default: '',
}, },
taskStatus: { taskStatus: {
type: String, type: String,
required: true, required: false,
default: '',
}, },
}, },
data() { data() {
...@@ -83,6 +85,7 @@ ...@@ -83,6 +85,7 @@
<template> <template>
<div <div
v-if="descriptionHtml"
class="description" class="description"
:class="{ :class="{
'js-task-list-container': canUpdate 'js-task-list-container': canUpdate
......
<script>
import updateMixin from '../mixins/update';
import eventHub from '../event_hub';
export default {
mixins: [updateMixin],
props: {
canDestroy: {
type: Boolean,
required: true,
},
formState: {
type: Object,
required: true,
},
},
data() {
return {
deleteLoading: false,
};
},
computed: {
isSubmitEnabled() {
return this.formState.title.trim() !== '';
},
},
methods: {
closeForm() {
eventHub.$emit('close.form');
},
deleteIssuable() {
// eslint-disable-next-line no-alert
if (confirm('Issue will be removed! Are you sure?')) {
this.deleteLoading = true;
eventHub.$emit('delete.issuable');
}
},
},
};
</script>
<template>
<div class="prepend-top-default append-bottom-default clearfix">
<button
class="btn btn-save pull-left"
:class="{ disabled: formState.updateLoading || !isSubmitEnabled }"
type="submit"
:disabled="formState.updateLoading || !isSubmitEnabled"
@click.prevent="updateIssuable">
Save changes
<i
class="fa fa-spinner fa-spin"
aria-hidden="true"
v-if="formState.updateLoading">
</i>
</button>
<button
class="btn btn-default pull-right"
type="button"
@click="closeForm">
Cancel
</button>
<button
v-if="canDestroy"
class="btn btn-danger pull-right append-right-default"
:class="{ disabled: deleteLoading }"
type="button"
:disabled="deleteLoading"
@click="deleteIssuable">
Delete
<i
class="fa fa-spinner fa-spin"
aria-hidden="true"
v-if="deleteLoading">
</i>
</button>
</div>
</template>
<script>
export default {
props: {
formState: {
type: Object,
required: true,
},
},
};
</script>
<template>
<fieldset class="checkbox">
<label for="issue-confidential">
<input
type="checkbox"
value="1"
id="issue-confidential"
v-model="formState.confidential" />
This issue is confidential and should only be visible to team members with at least Reporter access.
</label>
</fieldset>
</template>
<script>
/* global Flash */
import updateMixin from '../../mixins/update';
import markdownField from '../../../vue_shared/components/markdown/field.vue';
export default {
mixins: [updateMixin],
props: {
formState: {
type: Object,
required: true,
},
markdownPreviewUrl: {
type: String,
required: true,
},
markdownDocs: {
type: String,
required: true,
},
},
components: {
markdownField,
},
mounted() {
this.$refs.textarea.focus();
},
};
</script>
<template>
<div class="common-note-form">
<label
class="sr-only"
for="issue-description">
Description
</label>
<markdown-field
:markdown-preview-url="markdownPreviewUrl"
:markdown-docs="markdownDocs">
<textarea
id="issue-description"
class="note-textarea js-gfm-input js-autosize markdown-area"
data-supports-slash-commands="false"
aria-label="Description"
v-model="formState.description"
ref="textarea"
slot="textarea"
placeholder="Write a comment or drag your files here..."
@keydown.meta.enter="updateIssuable">
</textarea>
</markdown-field>
</div>
</template>
<script>
export default {
props: {
formState: {
type: Object,
required: true,
},
issuableTemplates: {
type: Array,
required: false,
default: () => [],
},
projectPath: {
type: String,
required: true,
},
projectNamespace: {
type: String,
required: true,
},
},
computed: {
issuableTemplatesJson() {
return JSON.stringify(this.issuableTemplates);
},
},
mounted() {
// Create the editor for the template
const editor = document.querySelector('.detail-page-description .note-textarea') || {};
editor.setValue = (val) => {
this.formState.description = val;
};
editor.getValue = () => this.formState.description;
this.issuableTemplate = new gl.IssuableTemplateSelectors({
$dropdowns: $(this.$refs.toggle),
editor,
});
},
};
</script>
<template>
<div
class="dropdown js-issuable-selector-wrap"
data-issuable-type="issue">
<button
class="dropdown-menu-toggle js-issuable-selector"
type="button"
ref="toggle"
data-field-name="issuable_template"
data-selected="null"
data-toggle="dropdown"
:data-namespace-path="projectNamespace"
:data-project-path="projectPath"
:data-data="issuableTemplatesJson">
<span class="dropdown-toggle-text">
Choose a template
</span>
<i
aria-hidden="true"
class="fa fa-chevron-down">
</i>
</button>
<div class="dropdown-menu dropdown-select">
<div class="dropdown-title">
Choose a template
<button
class="dropdown-title-button dropdown-menu-close"
aria-label="Close"
type="button">
<i
aria-hidden="true"
class="fa fa-times dropdown-menu-close-icon">
</i>
</button>
</div>
<div class="dropdown-input">
<input
type="search"
class="dropdown-input-field"
placeholder="Filter"
autocomplete="off" />
<i
aria-hidden="true"
class="fa fa-search dropdown-input-search">
</i>
<i
role="button"
aria-label="Clear templates search input"
class="fa fa-times dropdown-input-clear js-dropdown-input-clear">
</i>
</div>
<div class="dropdown-content"></div>
<div class="dropdown-footer">
<ul class="dropdown-footer-list">
<li>
<a class="no-template">
No template
</a>
</li>
<li>
<a class="reset-template">
Reset template
</a>
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
import tooltipMixin from '../../../vue_shared/mixins/tooltip';
export default {
mixins: [
tooltipMixin,
],
props: {
formState: {
type: Object,
required: true,
},
projectsAutocompleteUrl: {
type: String,
required: true,
},
},
mounted() {
const $moveDropdown = $(this.$refs['move-dropdown']);
$moveDropdown.select2({
ajax: {
url: this.projectsAutocompleteUrl,
quietMillis: 125,
data(term, page, context) {
return {
search: term,
offset_id: context,
};
},
results(data) {
const more = data.length >= 50;
const context = data[data.length - 1] ? data[data.length - 1].id : null;
return {
results: data,
more,
context,
};
},
},
formatResult(project) {
return project.name_with_namespace;
},
formatSelection(project) {
return project.name_with_namespace;
},
})
.on('change', (e) => {
this.formState.move_to_project_id = parseInt(e.target.value, 10);
});
},
beforeDestroy() {
$(this.$refs['move-dropdown']).select2('destroy');
},
};
</script>
<template>
<fieldset>
<label
for="issuable-move"
class="sr-only">
Move
</label>
<div class="issuable-form-select-holder append-right-5">
<input
ref="move-dropdown"
type="hidden"
id="issuable-move"
data-placeholder="Move to a different project" />
</div>
<span
data-placement="auto top"
title="Moving an issue will copy the discussion to a different project and close it here. All participants will be notified of the new location."
ref="tooltip">
<i
class="fa fa-question-circle"
aria-hidden="true">
</i>
</span>
</fieldset>
</template>
<script>
import updateMixin from '../../mixins/update';
export default {
mixins: [updateMixin],
props: {
formState: {
type: Object,
required: true,
},
},
};
</script>
<template>
<fieldset>
<label
class="sr-only"
for="issue-title">
Title
</label>
<input
id="issue-title"
class="form-control"
type="text"
placeholder="Issue title"
aria-label="Issue title"
v-model="formState.title"
@keydown.meta.enter="updateIssuable" />
</fieldset>
</template>
<script>
import lockedWarning from './locked_warning.vue';
import titleField from './fields/title.vue';
import descriptionField from './fields/description.vue';
import editActions from './edit_actions.vue';
import descriptionTemplate from './fields/description_template.vue';
import projectMove from './fields/project_move.vue';
import confidentialCheckbox from './fields/confidential_checkbox.vue';
export default {
props: {
canMove: {
type: Boolean,
required: true,
},
canDestroy: {
type: Boolean,
required: true,
},
formState: {
type: Object,
required: true,
},
issuableTemplates: {
type: Array,
required: false,
default: () => [],
},
markdownPreviewUrl: {
type: String,
required: true,
},
markdownDocs: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
projectNamespace: {
type: String,
required: true,
},
projectsAutocompleteUrl: {
type: String,
required: true,
},
},
components: {
lockedWarning,
titleField,
descriptionField,
descriptionTemplate,
editActions,
projectMove,
confidentialCheckbox,
},
computed: {
hasIssuableTemplates() {
return this.issuableTemplates.length;
},
},
};
</script>
<template>
<form>
<locked-warning v-if="formState.lockedWarningVisible" />
<div class="row">
<div
class="col-sm-4 col-lg-3"
v-if="hasIssuableTemplates">
<description-template
:form-state="formState"
:issuable-templates="issuableTemplates"
:project-path="projectPath"
:project-namespace="projectNamespace" />
</div>
<div
:class="{
'col-sm-8 col-lg-9': hasIssuableTemplates,
'col-xs-12': !hasIssuableTemplates,
}">
<title-field
:form-state="formState"
:issuable-templates="issuableTemplates" />
</div>
</div>
<description-field
:form-state="formState"
:markdown-preview-url="markdownPreviewUrl"
:markdown-docs="markdownDocs" />
<confidential-checkbox
:form-state="formState" />
<project-move
v-if="canMove"
:form-state="formState"
:projects-autocomplete-url="projectsAutocompleteUrl" />
<edit-actions
:form-state="formState"
:can-destroy="canDestroy" />
</form>
</template>
<script>
export default {
computed: {
currentPath() {
return location.pathname;
},
},
};
</script>
<template>
<div class="alert alert-danger">
Someone edited the issue at the same time you did. Please check out
<a
:href="currentPath"
target="_blank"
rel="nofollow">the issue</a>
and make sure your changes will not unintentionally remove theirs.
</div>
</template>
import Vue from 'vue';
export default new Vue();
import Vue from 'vue'; import Vue from 'vue';
import eventHub from './event_hub';
import issuableApp from './components/app.vue'; import issuableApp from './components/app.vue';
import '../vue_shared/vue_resource_interceptor'; import '../vue_shared/vue_resource_interceptor';
document.addEventListener('DOMContentLoaded', () => new Vue({ document.addEventListener('DOMContentLoaded', () => {
const initialDataEl = document.getElementById('js-issuable-app-initial-data');
const initialData = JSON.parse(initialDataEl.innerHTML.replace(/&quot;/g, '"'));
$('.issuable-edit').on('click', (e) => {
e.preventDefault();
eventHub.$emit('open.form');
});
return new Vue({
el: document.getElementById('js-issuable-app'), el: document.getElementById('js-issuable-app'),
components: { components: {
issuableApp, issuableApp,
}, },
data() { data() {
const issuableElement = this.$options.el;
const issuableTitleElement = issuableElement.querySelector('.title');
const issuableDescriptionElement = issuableElement.querySelector('.wiki');
const issuableDescriptionTextarea = issuableElement.querySelector('.js-task-list-field');
const {
canUpdate,
endpoint,
issuableRef,
} = issuableElement.dataset;
return { return {
canUpdate: gl.utils.convertPermissionToBoolean(canUpdate), ...initialData,
endpoint,
issuableRef,
initialTitle: issuableTitleElement.innerHTML,
initialDescriptionHtml: issuableDescriptionElement ? issuableDescriptionElement.innerHTML : '',
initialDescriptionText: issuableDescriptionTextarea ? issuableDescriptionTextarea.textContent : '',
}; };
}, },
render(createElement) { render(createElement) {
return createElement('issuable-app', { return createElement('issuable-app', {
props: { props: {
canUpdate: this.canUpdate, canUpdate: this.canUpdate,
canDestroy: this.canDestroy,
canMove: this.canMove,
endpoint: this.endpoint, endpoint: this.endpoint,
issuableRef: this.issuableRef, issuableRef: this.issuableRef,
initialTitle: this.initialTitle, initialTitleHtml: this.initialTitleHtml,
initialTitleText: this.initialTitleText,
initialDescriptionHtml: this.initialDescriptionHtml, initialDescriptionHtml: this.initialDescriptionHtml,
initialDescriptionText: this.initialDescriptionText, initialDescriptionText: this.initialDescriptionText,
issuableTemplates: this.issuableTemplates,
isConfidential: this.isConfidential,
markdownPreviewUrl: this.markdownPreviewUrl,
markdownDocs: this.markdownDocs,
projectPath: this.projectPath,
projectNamespace: this.projectNamespace,
projectsAutocompleteUrl: this.projectsAutocompleteUrl,
}, },
}); });
}, },
})); });
});
...@@ -4,7 +4,7 @@ export default { ...@@ -4,7 +4,7 @@ export default {
this.preAnimation = true; this.preAnimation = true;
this.pulseAnimation = false; this.pulseAnimation = false;
this.$nextTick(() => { setTimeout(() => {
this.preAnimation = false; this.preAnimation = false;
this.pulseAnimation = true; this.pulseAnimation = true;
}); });
......
import eventHub from '../event_hub';
export default {
methods: {
updateIssuable() {
this.formState.updateLoading = true;
eventHub.$emit('update.issuable');
},
},
};
...@@ -7,10 +7,23 @@ export default class Service { ...@@ -7,10 +7,23 @@ export default class Service {
constructor(endpoint) { constructor(endpoint) {
this.endpoint = endpoint; this.endpoint = endpoint;
this.resource = Vue.resource(this.endpoint); this.resource = Vue.resource(`${this.endpoint}.json`, {}, {
realtimeChanges: {
method: 'GET',
url: `${this.endpoint}/realtime_changes`,
},
});
} }
getData() { getData() {
return this.resource.get(); return this.resource.realtimeChanges();
}
deleteIssuable() {
return this.resource.delete();
}
updateIssuable(data) {
return this.resource.update(data);
} }
} }
export default class Store { export default class Store {
constructor({ constructor({
titleHtml, titleHtml,
titleText,
descriptionHtml, descriptionHtml,
descriptionText, descriptionText,
}) { }) {
this.state = { this.state = {
titleHtml, titleHtml,
titleText: '', titleText,
descriptionHtml, descriptionHtml,
descriptionText, descriptionText,
taskStatus: '', taskStatus: '',
updatedAt: '', updatedAt: '',
}; };
this.formState = {
title: '',
confidential: false,
description: '',
lockedWarningVisible: false,
move_to_project_id: 0,
updateLoading: false,
};
} }
updateState(data) { updateState(data) {
...@@ -22,4 +31,15 @@ export default class Store { ...@@ -22,4 +31,15 @@ export default class Store {
this.state.taskStatus = data.task_status; this.state.taskStatus = data.task_status;
this.state.updatedAt = data.updated_at; this.state.updatedAt = data.updated_at;
} }
stateShouldUpdate(data) {
return {
title: this.state.titleText !== data.title_text,
description: this.state.descriptionText !== data.description_text,
};
}
setFormState(state) {
this.formState = Object.assign(this.formState, state);
}
} }
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, one-var-declaration-per-line, consistent-return, prefer-arrow-callback, no-return-assign, object-shorthand, comma-dangle, no-param-reassign, max-len */ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, one-var-declaration-per-line, consistent-return, prefer-arrow-callback, no-return-assign, object-shorthand, comma-dangle, no-param-reassign, max-len */
(function() { function notificationGranted(message, opts, onclick) {
(function(w) {
var notificationGranted, notifyMe, notifyPermissions;
notificationGranted = function(message, opts, onclick) {
var notification; var notification;
notification = new Notification(message, opts); notification = new Notification(message, opts);
setTimeout(function() { setTimeout(function() {
return notification.close();
// Hide the notification after X amount of seconds // Hide the notification after X amount of seconds
return notification.close();
}, 8000); }, 8000);
if (onclick) {
return notification.onclick = onclick; return notification.onclick = onclick || notification.close;
} }
};
notifyPermissions = function() { function notifyPermissions() {
if ('Notification' in window) { if ('Notification' in window) {
return Notification.requestPermission(); return Notification.requestPermission();
} }
}; }
notifyMe = function(message, body, icon, onclick) {
function notifyMe(message, body, icon, onclick) {
var opts; var opts;
opts = { opts = {
body: body, body: body,
...@@ -27,7 +25,6 @@ ...@@ -27,7 +25,6 @@
}; };
// Let's check if the browser supports notifications // Let's check if the browser supports notifications
if (!('Notification' in window)) { if (!('Notification' in window)) {
// do nothing // do nothing
} else if (Notification.permission === 'granted') { } else if (Notification.permission === 'granted') {
// If it's okay let's create a notification // If it's okay let's create a notification
...@@ -40,8 +37,12 @@ ...@@ -40,8 +37,12 @@
} }
}); });
} }
}; }
w.notify = notifyMe;
return w.notifyPermissions = notifyPermissions; const notify = {
})(window); notificationGranted,
}).call(window); notifyPermissions,
notifyMe,
};
export default notify;
...@@ -42,3 +42,13 @@ export function formatRelevantDigits(number) { ...@@ -42,3 +42,13 @@ export function formatRelevantDigits(number) {
export function bytesToKiB(number) { export function bytesToKiB(number) {
return number / BYTES_IN_KIB; return number / BYTES_IN_KIB;
} }
/**
* Utility function that calculates MiB of the given bytes.
*
* @param {Number} number bytes
* @return {Number} MiB
*/
export function bytesToMiB(number) {
return number / (BYTES_IN_KIB * BYTES_IN_KIB);
}
...@@ -170,7 +170,7 @@ gl.text.init = function(form) { ...@@ -170,7 +170,7 @@ gl.text.init = function(form) {
}); });
}; };
gl.text.removeListeners = function(form) { gl.text.removeListeners = function(form) {
return $('.js-md', form).off(); return $('.js-md', form).off('click');
}; };
gl.text.humanize = function(string) { gl.text.humanize = function(string) {
return string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); return string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
......
...@@ -56,7 +56,6 @@ import './lib/utils/animate'; ...@@ -56,7 +56,6 @@ import './lib/utils/animate';
import './lib/utils/bootstrap_linked_tabs'; import './lib/utils/bootstrap_linked_tabs';
import './lib/utils/common_utils'; import './lib/utils/common_utils';
import './lib/utils/datetime_utility'; import './lib/utils/datetime_utility';
import './lib/utils/notify';
import './lib/utils/pretty_time'; import './lib/utils/pretty_time';
import './lib/utils/text_utility'; import './lib/utils/text_utility';
import './lib/utils/url_utility'; import './lib/utils/url_utility';
......
...@@ -30,7 +30,7 @@ ...@@ -30,7 +30,7 @@
| |
\\s\\$(?!\\$) \\s\\$(?!\\$)
) )
(.+?) ((.|\\n)+?)
( (
\\s\\\\end{[a-zA-Z]+}$ \\s\\\\end{[a-zA-Z]+}$
| |
...@@ -45,15 +45,25 @@ ...@@ -45,15 +45,25 @@
let inline = false; let inline = false;
if (typeof katex !== 'undefined') { if (typeof katex !== 'undefined') {
const katexString = text.replace(/\\/g, '\\'); const katexString = text.replace(/&amp;/g, '&')
const matches = new RegExp(katexRegexString, 'gi').exec(katexString); .replace(/&=&/g, '\\space=\\space')
.replace(/<(\/?)em>/g, '_');
const regex = new RegExp(katexRegexString, 'gi');
const matchLocation = katexString.search(regex);
const numberOfMatches = katexString.match(regex);
if (matches && matches.length > 0) { if (numberOfMatches && numberOfMatches.length !== 0) {
if (matches[1].trim() === '$' && matches[3].trim() === '$') { if (matchLocation > 0) {
let matches = regex.exec(katexString);
inline = true; inline = true;
text = `${katexString.replace(matches[0], '')} ${katex.renderToString(matches[2])}`; while (matches !== null) {
const renderedKatex = katex.renderToString(matches[0].replace(/\$/g, ''));
text = `${text.replace(matches[0], ` ${renderedKatex}`)}`;
matches = regex.exec(katexString);
}
} else { } else {
const matches = regex.exec(katexString);
text = katex.renderToString(matches[2]); text = katex.renderToString(matches[2]);
} }
} }
...@@ -79,7 +89,7 @@ ...@@ -79,7 +89,7 @@
}, },
computed: { computed: {
markdown() { markdown() {
return marked(this.cell.source.join('')); return marked(this.cell.source.join('').replace(/\\/g, '\\\\'));
}, },
}, },
}; };
......
This diff is collapsed.
<script> <script>
/* global Flash */
import Visibility from 'visibilityjs';
import Poll from '../../../lib/utils/poll';
import PipelineService from '../../services/pipeline_service';
import PipelineStore from '../../stores/pipeline_store';
import stageColumnComponent from './stage_column_component.vue'; import stageColumnComponent from './stage_column_component.vue';
import loadingIcon from '../../../vue_shared/components/loading_icon.vue'; import loadingIcon from '../../../vue_shared/components/loading_icon.vue';
import '../../../flash'; import '../../../flash';
export default { export default {
props: {
isLoading: {
type: Boolean,
required: true,
},
pipeline: {
type: Object,
required: true,
},
},
components: { components: {
stageColumnComponent, stageColumnComponent,
loadingIcon, loadingIcon,
}, },
data() { computed: {
const DOMdata = document.getElementById('js-pipeline-graph-vue').dataset; graph() {
const store = new PipelineStore(); return this.pipeline.details && this.pipeline.details.stages;
return {
isLoading: false,
endpoint: DOMdata.endpoint,
store,
state: store.state,
};
}, },
created() {
this.service = new PipelineService(this.endpoint);
const poll = new Poll({
resource: this.service,
method: 'getPipeline',
successCallback: this.successCallback,
errorCallback: this.errorCallback,
});
if (!Visibility.hidden()) {
this.isLoading = true;
poll.makeRequest();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
poll.restart();
} else {
poll.stop();
}
});
}, },
methods: { methods: {
successCallback(response) {
const data = response.json();
this.isLoading = false;
this.store.storeGraph(data.details.stages);
},
errorCallback() {
this.isLoading = false;
return new Flash('An error occurred while fetching the pipeline.');
},
capitalizeStageName(name) { capitalizeStageName(name) {
return name.charAt(0).toUpperCase() + name.slice(1); return name.charAt(0).toUpperCase() + name.slice(1);
}, },
...@@ -101,7 +65,7 @@ ...@@ -101,7 +65,7 @@
v-if="!isLoading" v-if="!isLoading"
class="stage-column-list"> class="stage-column-list">
<stage-column-component <stage-column-component
v-for="(stage, index) in state.graph" v-for="(stage, index) in graph"
:title="capitalizeStageName(stage.name)" :title="capitalizeStageName(stage.name)"
:jobs="stage.groups" :jobs="stage.groups"
:key="stage.name" :key="stage.name"
......
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
export default {
props: [
'pipeline',
],
computed: {
user() {
return !!this.pipeline.user;
},
},
components: {
userAvatarLink,
},
template: `
<td>
<a
:href="pipeline.path"
class="js-pipeline-url-link">
<span class="pipeline-id">#{{pipeline.id}}</span>
</a>
<span>by</span>
<user-avatar-link
v-if="user"
class="js-pipeline-url-user"
:link-href="pipeline.user.web_url"
:img-src="pipeline.user.avatar_url"
:tooltip-text="pipeline.user.name"
/>
<span
v-if="!user"
class="js-pipeline-url-api api">
API
</span>
<span
v-if="pipeline.flags.latest"
class="js-pipeline-url-lastest label label-success has-tooltip"
title="Latest pipeline for this branch"
data-original-title="Latest pipeline for this branch">
latest
</span>
<span
v-if="pipeline.flags.yaml_errors"
class="js-pipeline-url-yaml label label-danger has-tooltip"
:title="pipeline.yaml_errors"
:data-original-title="pipeline.yaml_errors">
yaml invalid
</span>
<span
v-if="pipeline.flags.stuck"
class="js-pipeline-url-stuck label label-warning">
stuck
</span>
</td>
`,
};
<script>
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import tooltipMixin from '../../vue_shared/mixins/tooltip';
export default {
props: {
pipeline: {
type: Object,
required: true,
},
},
components: {
userAvatarLink,
},
mixins: [
tooltipMixin,
],
computed: {
user() {
return this.pipeline.user;
},
},
};
</script>
<template>
<td>
<a
:href="pipeline.path"
class="js-pipeline-url-link">
<span class="pipeline-id">#{{pipeline.id}}</span>
</a>
<span>by</span>
<user-avatar-link
v-if="user"
class="js-pipeline-url-user"
:link-href="pipeline.user.web_url"
:img-src="pipeline.user.avatar_url"
:tooltip-text="pipeline.user.name"
/>
<span
v-if="!user"
class="js-pipeline-url-api api">
API
</span>
<span
v-if="pipeline.flags.latest"
class="js-pipeline-url-lastest label label-success"
title="Latest pipeline for this branch"
ref="tooltip">
latest
</span>
<span
v-if="pipeline.flags.yaml_errors"
class="js-pipeline-url-yaml label label-danger"
:title="pipeline.yaml_errors"
ref="tooltip">
yaml invalid
</span>
<span
v-if="pipeline.flags.stuck"
class="js-pipeline-url-stuck label label-warning">
stuck
</span>
</td>
</template>
import Vue from 'vue';
import pipelineGraph from './components/graph/graph_component.vue';
document.addEventListener('DOMContentLoaded', () => new Vue({
el: '#js-pipeline-graph-vue',
components: {
pipelineGraph,
},
render: createElement => createElement('pipeline-graph'),
}));
import Vue from 'vue';
import PipelinesMediator from './pipeline_details_mediatior';
import pipelineGraph from './components/graph/graph_component.vue';
document.addEventListener('DOMContentLoaded', () => {
const dataset = document.querySelector('.js-pipeline-details-vue').dataset;
const mediator = new PipelinesMediator({ endpoint: dataset.endpoint });
mediator.fetchPipeline();
const pipelineGraphApp = new Vue({
el: '#js-pipeline-graph-vue',
data() {
return {
mediator,
};
},
components: {
pipelineGraph,
},
render(createElement) {
return createElement('pipeline-graph', {
props: {
isLoading: this.mediator.state.isLoading,
pipeline: this.mediator.store.state.pipeline,
},
});
},
});
return pipelineGraphApp;
});
/* global Flash */
import Visibility from 'visibilityjs';
import Poll from '../lib/utils/poll';
import PipelineStore from './stores/pipeline_store';
import PipelineService from './services/pipeline_service';
export default class pipelinesMediator {
constructor(options = {}) {
this.options = options;
this.store = new PipelineStore();
this.service = new PipelineService(options.endpoint);
this.state = {};
this.state.isLoading = false;
}
fetchPipeline() {
this.poll = new Poll({
resource: this.service,
method: 'getPipeline',
successCallback: this.successCallback.bind(this),
errorCallback: this.errorCallback.bind(this),
});
if (!Visibility.hidden()) {
this.state.isLoading = true;
this.poll.makeRequest();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
this.poll.restart();
} else {
this.poll.stop();
}
});
}
successCallback(response) {
const data = response.json();
this.state.isLoading = false;
this.store.storePipeline(data);
}
errorCallback() {
this.state.isLoading = false;
return new Flash('An error occurred while fetching the pipeline.');
}
}
...@@ -2,10 +2,10 @@ export default class PipelineStore { ...@@ -2,10 +2,10 @@ export default class PipelineStore {
constructor() { constructor() {
this.state = {}; this.state = {};
this.state.graph = []; this.state.pipeline = {};
} }
storeGraph(graph = []) { storePipeline(pipeline = {}) {
this.state.graph = graph; this.state.pipeline = pipeline;
} }
} }
...@@ -51,6 +51,9 @@ import Api from './api'; ...@@ -51,6 +51,9 @@ import Api from './api';
this.groupId = $(select).data('group-id'); this.groupId = $(select).data('group-id');
this.includeGroups = $(select).data('include-groups'); this.includeGroups = $(select).data('include-groups');
this.orderBy = $(select).data('order-by') || 'id'; this.orderBy = $(select).data('order-by') || 'id';
this.withIssuesEnabled = $(select).data('with-issues-enabled');
this.withMergeRequestsEnabled = $(select).data('with-merge-requests-enabled');
placeholder = "Search for project"; placeholder = "Search for project";
if (this.includeGroups) { if (this.includeGroups) {
placeholder += " or group"; placeholder += " or group";
...@@ -84,7 +87,11 @@ import Api from './api'; ...@@ -84,7 +87,11 @@ import Api from './api';
if (_this.groupId) { if (_this.groupId) {
return Api.groupProjects(_this.groupId, query.term, projectsCallback); return Api.groupProjects(_this.groupId, query.term, projectsCallback);
} else { } else {
return Api.projects(query.term, { order_by: _this.orderBy }, projectsCallback); return Api.projects(query.term, {
order_by: _this.orderBy,
with_issues_enabled: _this.withIssuesEnabled,
with_merge_requests_enabled: _this.withMergeRequestsEnabled
}, projectsCallback);
} }
}; };
})(this), })(this),
......
...@@ -77,7 +77,9 @@ import './shortcuts_navigation'; ...@@ -77,7 +77,9 @@ import './shortcuts_navigation';
ShortcutsIssuable.prototype.editIssue = function() { ShortcutsIssuable.prototype.editIssue = function() {
var $editBtn; var $editBtn;
$editBtn = $('.issuable-edit'); $editBtn = $('.issuable-edit');
return gl.utils.visitUrl($editBtn.attr('href')); // Need to click the element as on issues, editing is inline
// on merge request, editing is on a different page
$editBtn.get(0).click();
}; };
ShortcutsIssuable.prototype.openSidebarDropdown = function(name) { ShortcutsIssuable.prototype.openSidebarDropdown = function(name) {
......
/* global Flash */ /* global Flash */
import 'vendor/task_list'; import 'deckar01-task_list';
class TaskList { class TaskList {
constructor(options = {}) { constructor(options = {}) {
......
...@@ -35,6 +35,7 @@ function UsersSelect(currentUser, els) { ...@@ -35,6 +35,7 @@ function UsersSelect(currentUser, els) {
options.showCurrentUser = $dropdown.data('current-user'); options.showCurrentUser = $dropdown.data('current-user');
options.todoFilter = $dropdown.data('todo-filter'); options.todoFilter = $dropdown.data('todo-filter');
options.todoStateFilter = $dropdown.data('todo-state-filter'); options.todoStateFilter = $dropdown.data('todo-state-filter');
options.perPage = $dropdown.data('per-page');
showNullUser = $dropdown.data('null-user'); showNullUser = $dropdown.data('null-user');
defaultNullUser = $dropdown.data('null-user-default'); defaultNullUser = $dropdown.data('null-user-default');
showMenuAbove = $dropdown.data('showMenuAbove'); showMenuAbove = $dropdown.data('showMenuAbove');
...@@ -214,7 +215,36 @@ function UsersSelect(currentUser, els) { ...@@ -214,7 +215,36 @@ function UsersSelect(currentUser, els) {
glDropdown.options.processData(term, users, callback); glDropdown.options.processData(term, users, callback);
}.bind(this)); }.bind(this));
}, },
processData: function(term, users, callback) { processData: function(term, data, callback) {
let users = data;
// Only show assigned user list when there is no search term
if ($dropdown.hasClass('js-multiselect') && term.length === 0) {
const selectedInputs = getSelectedUserInputs();
// Potential duplicate entries when dealing with issue board
// because issue board is also managed by vue
const selectedUsers = _.uniq(selectedInputs, false, a => a.value)
.filter((input) => {
const userId = parseInt(input.value, 10);
const inUsersArray = users.find(u => u.id === userId);
return !inUsersArray && userId !== 0;
})
.map((input) => {
const userId = parseInt(input.value, 10);
const { avatarUrl, avatar_url, name, username } = input.dataset;
return {
avatar_url: avatarUrl || avatar_url,
id: userId,
name,
username,
};
});
users = data.concat(selectedUsers);
}
let anyUser; let anyUser;
let index; let index;
let j; let j;
...@@ -645,7 +675,7 @@ UsersSelect.prototype.users = function(query, options, callback) { ...@@ -645,7 +675,7 @@ UsersSelect.prototype.users = function(query, options, callback) {
url: url, url: url,
data: { data: {
search: query, search: query,
per_page: 20, per_page: options.perPage || 20,
active: true, active: true,
project_id: options.projectId || null, project_id: options.projectId || null,
group_id: options.groupId || null, group_id: options.groupId || null,
......
import statusCodes from '~/lib/utils/http_status'; import statusCodes from '~/lib/utils/http_status';
import { bytesToMiB } from '~/lib/utils/number_utils';
import MemoryGraph from '../../vue_shared/components/memory_graph'; import MemoryGraph from '../../vue_shared/components/memory_graph';
import MRWidgetService from '../services/mr_widget_service'; import MRWidgetService from '../services/mr_widget_service';
...@@ -9,8 +11,8 @@ export default { ...@@ -9,8 +11,8 @@ export default {
}, },
data() { data() {
return { return {
// memoryFrom: 0, memoryFrom: 0,
// memoryTo: 0, memoryTo: 0,
memoryMetrics: [], memoryMetrics: [],
deploymentTime: 0, deploymentTime: 0,
hasMetrics: false, hasMetrics: false,
...@@ -35,18 +37,38 @@ export default { ...@@ -35,18 +37,38 @@ export default {
shouldShowMetricsUnavailable() { shouldShowMetricsUnavailable() {
return !this.loadingMetrics && !this.hasMetrics && !this.loadFailed; return !this.loadingMetrics && !this.hasMetrics && !this.loadFailed;
}, },
memoryChangeType() {
const memoryTo = Number(this.memoryTo);
const memoryFrom = Number(this.memoryFrom);
if (memoryTo > memoryFrom) {
return 'increased';
} else if (memoryTo < memoryFrom) {
return 'decreased';
}
return 'unchanged';
},
}, },
methods: { methods: {
getMegabytes(bytesString) {
const valueInBytes = Number(bytesString).toFixed(2);
return (bytesToMiB(valueInBytes)).toFixed(2);
},
computeGraphData(metrics, deploymentTime) { computeGraphData(metrics, deploymentTime) {
this.loadingMetrics = false; this.loadingMetrics = false;
const { memory_values } = metrics; const { memory_before, memory_after, memory_values } = metrics;
// if (memory_previous.length > 0) {
// this.memoryFrom = Number(memory_previous[0].value[1]).toFixed(2); // Both `memory_before` and `memory_after` objects
// } // have peculiar structure where accessing only a specific
// // index yeilds correct value that we can use to show memory delta.
// if (memory_current.length > 0) { if (memory_before.length > 0) {
// this.memoryTo = Number(memory_current[0].value[1]).toFixed(2); this.memoryFrom = this.getMegabytes(memory_before[0].value[1]);
// } }
if (memory_after.length > 0) {
this.memoryTo = this.getMegabytes(memory_after[0].value[1]);
}
if (memory_values.length > 0) { if (memory_values.length > 0) {
this.hasMetrics = true; this.hasMetrics = true;
...@@ -102,7 +124,7 @@ export default { ...@@ -102,7 +124,7 @@ export default {
<p <p
v-if="shouldShowMemoryGraph" v-if="shouldShowMemoryGraph"
class="usage-info js-usage-info"> class="usage-info js-usage-info">
Deployment memory usage: Memory usage <b>{{memoryChangeType}}</b> from {{memoryFrom}}MB to {{memoryTo}}MB
</p> </p>
<p <p
v-if="shouldShowLoadFailure" v-if="shouldShowLoadFailure"
......
...@@ -13,7 +13,7 @@ export default { ...@@ -13,7 +13,7 @@ export default {
}, },
data() { data() {
return { return {
removeSourceBranch: true, removeSourceBranch: this.mr.shouldRemoveSourceBranch,
mergeWhenBuildSucceeds: false, mergeWhenBuildSucceeds: false,
useCommitMessageWithDescription: false, useCommitMessageWithDescription: false,
setToMergeWhenPipelineSucceeds: false, setToMergeWhenPipelineSucceeds: false,
...@@ -69,6 +69,9 @@ export default { ...@@ -69,6 +69,9 @@ export default {
|| this.isMakingRequest || this.isMakingRequest
|| this.mr.preventMerge); || this.mr.preventMerge);
}, },
isRemoveSourceBranchButtonDisabled() {
return this.isMergeButtonDisabled || !this.mr.canRemoveSourceBranch;
},
shouldShowSquashBeforeMerge() { shouldShowSquashBeforeMerge() {
const { commitsCount, enableSquashBeforeMerge } = this.mr; const { commitsCount, enableSquashBeforeMerge } = this.mr;
return enableSquashBeforeMerge && commitsCount > 1; return enableSquashBeforeMerge && commitsCount > 1;
...@@ -252,8 +255,9 @@ export default { ...@@ -252,8 +255,9 @@ export default {
<template v-if="isMergeAllowed()"> <template v-if="isMergeAllowed()">
<label class="spacing"> <label class="spacing">
<input <input
id="remove-source-branch-input"
v-model="removeSourceBranch" v-model="removeSourceBranch"
:disabled="isMergeButtonDisabled" :disabled="isRemoveSourceBranchButtonDisabled"
type="checkbox"/> Remove source branch type="checkbox"/> Remove source branch
</label> </label>
......
...@@ -41,3 +41,4 @@ export { default as getStateKey } from './stores/get_state_key'; ...@@ -41,3 +41,4 @@ export { default as getStateKey } from './stores/get_state_key';
export { default as mrWidgetOptions } from './mr_widget_options'; export { default as mrWidgetOptions } from './mr_widget_options';
export { default as stateMaps } from './stores/state_maps'; export { default as stateMaps } from './stores/state_maps';
export { default as SquashBeforeMerge } from './components/states/mr_widget_squash_before_merge'; export { default as SquashBeforeMerge } from './components/states/mr_widget_squash_before_merge';
export { default as notify } from '../lib/utils/notify';
...@@ -4,6 +4,8 @@ import { ...@@ -4,6 +4,8 @@ import {
} from './dependencies'; } from './dependencies';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
gl.mrWidgetData.gitlabLogo = gon.gitlab_logo;
const vm = new Vue(mrWidgetOptions); const vm = new Vue(mrWidgetOptions);
window.gl.mrWidget = { window.gl.mrWidget = {
......
...@@ -29,6 +29,7 @@ import { ...@@ -29,6 +29,7 @@ import {
eventHub, eventHub,
stateMaps, stateMaps,
SquashBeforeMerge, SquashBeforeMerge,
notify,
} from './dependencies'; } from './dependencies';
export default { export default {
...@@ -77,8 +78,10 @@ export default { ...@@ -77,8 +78,10 @@ export default {
this.service.checkStatus() this.service.checkStatus()
.then(res => res.json()) .then(res => res.json())
.then((res) => { .then((res) => {
this.handleNotification(res);
this.mr.setData(res); this.mr.setData(res);
this.setFavicon(); this.setFavicon();
if (cb) { if (cb) {
cb.call(null, res); cb.call(null, res);
} }
...@@ -136,6 +139,15 @@ export default { ...@@ -136,6 +139,15 @@ export default {
new Flash('Something went wrong. Please try again.'); // eslint-disable-line new Flash('Something went wrong. Please try again.'); // eslint-disable-line
}); });
}, },
handleNotification(data) {
if (data.ci_status === this.mr.ciStatus) return;
const label = data.pipeline.details.status.label;
const title = `Pipeline ${label}`;
const message = `Pipeline ${label} for "${data.title}"`;
notify.notifyMe(title, message, this.mr.gitlabLogo);
},
resumePolling() { resumePolling() {
this.pollingInterval.resume(); this.pollingInterval.resume();
}, },
......
...@@ -5,6 +5,8 @@ export default class MergeRequestStore { ...@@ -5,6 +5,8 @@ export default class MergeRequestStore {
constructor(data) { constructor(data) {
this.sha = data.diff_head_sha; this.sha = data.diff_head_sha;
this.gitlabLogo = data.gitlabLogo;
this.setData(data); this.setData(data);
} }
...@@ -50,7 +52,7 @@ export default class MergeRequestStore { ...@@ -50,7 +52,7 @@ export default class MergeRequestStore {
this.cancelAutoMergePath = data.cancel_merge_when_pipeline_succeeds_path; this.cancelAutoMergePath = data.cancel_merge_when_pipeline_succeeds_path;
this.removeWIPPath = data.remove_wip_path; this.removeWIPPath = data.remove_wip_path;
this.sourceBranchRemoved = !data.source_branch_exists; this.sourceBranchRemoved = !data.source_branch_exists;
this.shouldRemoveSourceBranch = (data.merge_params || {}).should_remove_source_branch || false; this.shouldRemoveSourceBranch = data.remove_source_branch || false;
this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false; this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false;
this.mergeWhenPipelineSucceeds = data.merge_when_pipeline_succeeds || false; this.mergeWhenPipelineSucceeds = data.merge_when_pipeline_succeeds || false;
this.mergePath = data.merge_path; this.mergePath = data.merge_path;
......
<script>
/* global Flash */
import markdownHeader from './header.vue';
import markdownToolbar from './toolbar.vue';
export default {
props: {
markdownPreviewUrl: {
type: String,
required: false,
default: '',
},
markdownDocs: {
type: String,
required: true,
},
},
data() {
return {
markdownPreview: '',
markdownPreviewLoading: false,
previewMarkdown: false,
};
},
components: {
markdownHeader,
markdownToolbar,
},
methods: {
toggleMarkdownPreview() {
this.previewMarkdown = !this.previewMarkdown;
if (!this.previewMarkdown) {
this.markdownPreview = '';
} else {
this.markdownPreviewLoading = true;
this.$http.post(
this.markdownPreviewUrl,
{
/*
Can't use `$refs` as the component is technically in the parent component
so we access the VNode & then get the element
*/
text: this.$slots.textarea[0].elm.value,
},
)
.then((res) => {
const data = res.json();
this.markdownPreviewLoading = false;
this.markdownPreview = data.body;
this.$nextTick(() => {
$(this.$refs['markdown-preview']).renderGFM();
});
})
.catch(() => new Flash('Error loading markdown preview'));
}
},
},
mounted() {
/*
GLForm class handles all the toolbar buttons
*/
return new gl.GLForm($(this.$refs['gl-form']), true);
},
};
</script>
<template>
<div
class="md-area prepend-top-default append-bottom-default js-vue-markdown-field"
ref="gl-form">
<markdown-header
:preview-markdown="previewMarkdown"
@toggle-markdown="toggleMarkdownPreview" />
<div
class="md-write-holder"
v-show="!previewMarkdown">
<div class="zen-backdrop">
<slot name="textarea"></slot>
<a
class="zen-control zen-control-leave js-zen-leave"
href="#"
aria-label="Enter zen mode">
<i
class="fa fa-compress"
aria-hidden="true">
</i>
</a>
<markdown-toolbar
:markdown-docs="markdownDocs" />
</div>
</div>
<div
class="md md-preview-holder md-preview"
v-show="previewMarkdown">
<div
ref="markdown-preview"
v-html="markdownPreview">
</div>
<span v-if="markdownPreviewLoading">
Loading...
</span>
</div>
</div>
</template>
<script>
import tooltipMixin from '../../mixins/tooltip';
import toolbarButton from './toolbar_button.vue';
export default {
mixins: [
tooltipMixin,
],
props: {
previewMarkdown: {
type: Boolean,
required: true,
},
},
components: {
toolbarButton,
},
methods: {
toggleMarkdownPreview(e, form) {
if (form && !form.find('.js-vue-markdown-field').length) {
return;
} else if (e.target.blur) {
e.target.blur();
}
this.$emit('toggle-markdown');
},
},
mounted() {
$(document).on('markdown-preview:show.vue', this.toggleMarkdownPreview);
$(document).on('markdown-preview:hide.vue', this.toggleMarkdownPreview);
},
beforeDestroy() {
$(document).on('markdown-preview:show.vue', this.toggleMarkdownPreview);
$(document).off('markdown-preview:hide.vue', this.toggleMarkdownPreview);
},
};
</script>
<template>
<div class="md-header">
<ul class="nav-links clearfix">
<li :class="{ active: !previewMarkdown }">
<a
href="#md-write-holder"
tabindex="-1"
@click.prevent="toggleMarkdownPreview($event)">
Write
</a>
</li>
<li :class="{ active: previewMarkdown }">
<a
href="#md-preview-holder"
tabindex="-1"
@click.prevent="toggleMarkdownPreview($event)">
Preview
</a>
</li>
<li class="pull-right">
<div class="toolbar-group">
<toolbar-button
tag="**"
button-title="Add bold text"
icon="bold" />
<toolbar-button
tag="*"
button-title="Add italic text"
icon="italic" />
<toolbar-button
tag="> "
:prepend="true"
button-title="Insert a quote"
icon="quote-right" />
<toolbar-button
tag="`"
tag-block="```"
button-title="Insert code"
icon="code" />
<toolbar-button
tag="* "
:prepend="true"
button-title="Add a bullet list"
icon="list-ul" />
<toolbar-button
tag="1. "
:prepend="true"
button-title="Add a numbered list"
icon="list-ol" />
<toolbar-button
tag="* [ ] "
:prepend="true"
button-title="Add a task list"
icon="check-square-o" />
</div>
<div class="toolbar-group">
<button
aria-label="Go full screen"
class="toolbar-btn js-zen-enter"
data-container="body"
tabindex="-1"
title="Go full screen"
type="button"
ref="tooltip">
<i
aria-hidden="true"
class="fa fa-arrows-alt fa-fw">
</i>
</button>
</div>
</li>
</ul>
</div>
</template>
<script>
export default {
props: {
markdownDocs: {
type: String,
required: true,
},
},
};
</script>
<template>
<div class="comment-toolbar clearfix">
<div class="toolbar-text">
<a
:href="markdownDocs"
target="_blank"
tabindex="-1">
Markdown is supported
</a>
</div>
<button
class="toolbar-button markdown-selector"
type="button"
tabindex="-1">
<i
class="fa fa-file-image-o toolbar-button-icon"
aria-hidden="true">
</i>
Attach a file
</button>
</div>
</template>
<script>
import tooltipMixin from '../../mixins/tooltip';
export default {
mixins: [
tooltipMixin,
],
props: {
buttonTitle: {
type: String,
required: true,
},
icon: {
type: String,
required: true,
},
tag: {
type: String,
required: true,
},
tagBlock: {
type: String,
required: false,
default: '',
},
prepend: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
iconClass() {
return `fa-${this.icon}`;
},
},
};
</script>
<template>
<button
type="button"
class="toolbar-btn js-md hidden-xs"
tabindex="-1"
ref="tooltip"
data-container="body"
:data-md-tag="tag"
:data-md-block="tagBlock"
:data-md-prepend="prepend"
:title="buttonTitle"
:aria-label="buttonTitle">
<i
aria-hidden="true"
class="fa fa-fw"
:class="iconClass">
</i>
</button>
</template>
...@@ -4,7 +4,7 @@ import PipelinesActionsComponent from '../../pipelines/components/pipelines_acti ...@@ -4,7 +4,7 @@ import PipelinesActionsComponent from '../../pipelines/components/pipelines_acti
import PipelinesArtifactsComponent from '../../pipelines/components/pipelines_artifacts'; import PipelinesArtifactsComponent from '../../pipelines/components/pipelines_artifacts';
import ciBadge from './ci_badge_link.vue'; import ciBadge from './ci_badge_link.vue';
import PipelinesStageComponent from '../../pipelines/components/stage.vue'; import PipelinesStageComponent from '../../pipelines/components/stage.vue';
import PipelinesUrlComponent from '../../pipelines/components/pipeline_url'; import PipelinesUrlComponent from '../../pipelines/components/pipeline_url.vue';
import PipelinesTimeagoComponent from '../../pipelines/components/time_ago'; import PipelinesTimeagoComponent from '../../pipelines/components/time_ago';
import CommitComponent from './commit'; import CommitComponent from './commit';
......
...@@ -6,4 +6,8 @@ export default { ...@@ -6,4 +6,8 @@ export default {
updated() { updated() {
$(this.$refs.tooltip).tooltip('fixTitle'); $(this.$refs.tooltip).tooltip('fixTitle');
}, },
beforeDestroy() {
$(this.$refs.tooltip).tooltip('destroy');
},
}; };
...@@ -34,6 +34,7 @@ ...@@ -34,6 +34,7 @@
@import "framework/selects.scss"; @import "framework/selects.scss";
@import "framework/sidebar.scss"; @import "framework/sidebar.scss";
@import "framework/tables.scss"; @import "framework/tables.scss";
@import "framework/notes.scss";
@import "framework/timeline.scss"; @import "framework/timeline.scss";
@import "framework/typography.scss"; @import "framework/typography.scss";
@import "framework/zen.scss"; @import "framework/zen.scss";
......
...@@ -23,7 +23,6 @@ ...@@ -23,7 +23,6 @@
.row-content-block { .row-content-block {
margin-top: 0; margin-top: 0;
margin-bottom: -$gl-padding;
background-color: $gray-light; background-color: $gray-light;
padding: $gl-padding; padding: $gl-padding;
margin-bottom: 0; margin-bottom: 0;
......
gl-emoji { gl-emoji {
display: inline-block;
display: inline-flex; display: inline-flex;
vertical-align: middle; vertical-align: middle;
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
......
...@@ -104,6 +104,22 @@ ...@@ -104,6 +104,22 @@
padding: 2px 7px; padding: 2px 7px;
} }
.name {
background-color: $filter-name-resting-color;
color: $filter-name-text-color;
border-radius: 2px 0 0 2px;
margin-right: 1px;
text-transform: capitalize;
}
.value-container {
background-color: $white-normal;
color: $filter-value-text-color;
border-radius: 0 2px 2px 0;
margin-right: 5px;
padding-right: 8px;
}
.value { .value {
padding-right: 0; padding-right: 0;
} }
...@@ -111,7 +127,7 @@ ...@@ -111,7 +127,7 @@
.remove-token { .remove-token {
display: inline-block; display: inline-block;
padding-left: 4px; padding-left: 4px;
padding-right: 8px; padding-right: 0;
.fa-close { .fa-close {
color: $gl-text-color-secondary; color: $gl-text-color-secondary;
...@@ -132,21 +148,6 @@ ...@@ -132,21 +148,6 @@
} }
} }
.name {
background-color: $filter-name-resting-color;
color: $filter-name-text-color;
border-radius: 2px 0 0 2px;
margin-right: 1px;
text-transform: capitalize;
}
.value-container {
background-color: $white-normal;
color: $filter-value-text-color;
border-radius: 0 2px 2px 0;
margin-right: 5px;
}
.selected { .selected {
.name { .name {
background-color: $filter-name-selected-color; background-color: $filter-name-selected-color;
...@@ -474,4 +475,5 @@ ...@@ -474,4 +475,5 @@
.filter-dropdown-loading { .filter-dropdown-loading {
padding: 8px 16px; padding: 8px 16px;
text-align: center;
} }
...@@ -11,7 +11,6 @@ ...@@ -11,7 +11,6 @@
> li { > li {
padding: 10px 15px; padding: 10px 15px;
min-height: 20px; min-height: 20px;
border-bottom: 1px solid $list-border-light;
border-bottom: 1px solid $list-border; border-bottom: 1px solid $list-border;
&::after { &::after {
......
@mixin notes-media($condition, $breakpoint-width) {
@media (#{$condition}-width: ($breakpoint-width)) {
@content;
}
// Diff is side by side
.notes_content.parallel & {
// We hide at double what we normally hide at because
// there are two columns of notes
@media (#{$condition}-width: (2 * $breakpoint-width)) {
@content;
}
}
}
...@@ -96,7 +96,6 @@ ...@@ -96,7 +96,6 @@
.select2-search-field input { .select2-search-field input {
padding: 5px $gl-padding / 2; padding: 5px $gl-padding / 2;
font-size: 13px;
height: auto; height: auto;
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;
......
...@@ -3,6 +3,12 @@ ...@@ -3,6 +3,12 @@
margin: 0; margin: 0;
padding: 0; padding: 0;
&::before {
@include notes-media('max', $screen-xs-max) {
background: none;
}
}
.system-note { .system-note {
.note-text { .note-text {
color: $gl-text-color !important; color: $gl-text-color !important;
...@@ -23,6 +29,16 @@ ...@@ -23,6 +29,16 @@
.timeline-entry-inner { .timeline-entry-inner {
position: relative; position: relative;
@include notes-media('max', $screen-xs-max) {
.timeline-icon {
display: none;
}
.timeline-content {
margin-left: 0;
}
}
} }
&:target, &:target,
...@@ -40,24 +56,6 @@ ...@@ -40,24 +56,6 @@
} }
} }
@media (max-width: $screen-xs-max) {
.timeline {
&::before {
background: none;
}
}
.timeline-entry .timeline-entry-inner {
.timeline-icon {
display: none;
}
.timeline-content {
margin-left: 0;
}
}
}
.discussion .timeline-entry { .discussion .timeline-entry {
margin: 0; margin: 0;
border-right: none; border-right: none;
......
...@@ -293,7 +293,7 @@ $btn-white-active: #848484; ...@@ -293,7 +293,7 @@ $btn-white-active: #848484;
/* /*
* Badges * Badges
*/ */
$badge-bg: #eee; $badge-bg: rgba(0, 0, 0, 0.07);
$badge-color: $gl-text-color-secondary; $badge-color: $gl-text-color-secondary;
/* /*
......
...@@ -72,7 +72,9 @@ ...@@ -72,7 +72,9 @@
@media (min-width: $screen-sm-min) { @media (min-width: $screen-sm-min) {
height: 475px; // Needed for PhantomJS height: 475px; // Needed for PhantomJS
// scss-lint:disable DuplicateProperty
height: calc(100vh - 222px); height: calc(100vh - 222px);
// scss-lint:enable DuplicateProperty
min-height: 475px; min-height: 475px;
transition: width .2s; transition: width .2s;
......
...@@ -29,65 +29,53 @@ ...@@ -29,65 +29,53 @@
} }
} }
.build-page { @keyframes blinking-scroll-button {
pre.trace { 0% { opacity: 0.2; }
background: $builds-trace-bg; 25% { opacity: 0.5; }
color: $white-light; 50% { opacity: 0.7; }
font-family: $monospace_font; 100% { opacity: 1; }
white-space: pre-wrap; }
overflow: auto;
overflow-y: hidden;
font-size: 12px;
.fa-spinner {
font-size: 24px;
margin-left: 20px;
}
}
.environment-information {
background-color: $gray-light;
border: 1px solid $border-color;
padding: 12px $gl-padding;
border-radius: $border-radius-default;
svg { .build-page {
position: relative; .sticky {
top: 1px; position: absolute;
margin-right: 5px; left: 0;
} right: 0;
} }
.truncated-info { .build-trace-container {
text-align: center; position: absolute;
border-bottom: 1px solid; top: 225px;
background-color: $black; left: 15px;
height: 45px; bottom: 10px;
padding: 15px; background: $black;
color: $gray-darkest;
font-family: $monospace_font;
font-size: 12px;
&.affix { &.sidebar-expanded {
top: 0; right: 305px;
} }
// with sidebar &.sidebar-collapsed {
&.affix.sidebar-expanded { right: 16px;
right: 312px;
left: 22px;
} }
// without sidebar code {
&.affix.sidebar-collapsed { background: $black;
right: 20px; color: $gray-darkest;
left: 20px;
} }
&.affix-top { .top-bar {
position: absolute;
top: 0; top: 0;
height: 35px;
display: flex;
justify-content: flex-end;
border-bottom: 1px outset $white-light;
.truncated-info {
margin: 0 auto; margin: 0 auto;
right: 5px; align-self: center;
left: 5px;
}
.truncated-info-size { .truncated-info-size {
margin: 0 5px; margin: 0 5px;
...@@ -99,59 +87,82 @@ ...@@ -99,59 +87,82 @@
text-decoration: underline; text-decoration: underline;
} }
} }
} }
.scroll-controls { .controllers {
height: 100%; display: flex;
align-self: center;
font-size: 15px;
.scroll-step { svg {
width: 31px; height: 15px;
margin: 0 0 0 auto; display: block;
fill: $white-light;
} }
.scroll-link, a,
.autoscroll-container { .btn-scroll {
right: 25px; margin: 0 8px;
z-index: 1; color: $white-light;
} }
.scroll-link { .btn-scroll.animate {
position: fixed; .first-triangle {
display: block; animation: blinking-scroll-button 1s ease infinite;
margin-bottom: 10px; animation-delay: .3s;
}
&.scroll-top .gitlab-icon-scroll-up-hover, .second-triangle {
&.scroll-top:hover .gitlab-icon-scroll-up, animation: blinking-scroll-button 1s ease infinite;
&.scroll-bottom .gitlab-icon-scroll-down-hover, animation-delay: .2s;
&.scroll-bottom:hover .gitlab-icon-scroll-down {
display: none;
} }
&.scroll-top:hover .gitlab-icon-scroll-up-hover, .third-triangle {
&.scroll-bottom:hover .gitlab-icon-scroll-down-hover { animation: blinking-scroll-button 1s ease infinite;
display: inline-block;
} }
&.scroll-top { &:disabled {
top: 10px; opacity: 1;
}
} }
&.scroll-bottom { .btn-scroll:disabled {
bottom: -2px; opacity: 0.35;
cursor: not-allowed;
}
} }
} }
.autoscroll-container { .bash {
position: absolute; top: 35px;
left: 10px;
bottom: 0;
overflow-y: hidden;
padding-bottom: 20px;
padding-right: 20px;
} }
&.sidebar-expanded { .environment-information {
background-color: $gray-light;
border: 1px solid $border-color;
padding: 12px $gl-padding;
border-radius: $border-radius-default;
.scroll-link, svg {
.autoscroll-container { position: relative;
right: ($gutter_width + ($gl-padding * 2)); top: 1px;
margin-right: 5px;
} }
} }
.build-loader-animation {
position: relative;
width: 6px;
height: 6px;
margin: auto auto 12px 2px;
border-radius: 50%;
animation: blinking-dots 1s linear infinite;
}
} }
.status-message { .status-message {
...@@ -223,32 +234,6 @@ ...@@ -223,32 +234,6 @@
} }
} }
.build-trace {
background: $black;
color: $gray-darkest;
white-space: pre;
overflow-x: auto;
font-size: 12px;
position: relative;
.fa-spinner {
font-size: 24px;
}
.bash {
display: block;
}
.build-loader-animation {
position: relative;
width: 6px;
height: 6px;
margin: auto auto 12px 2px;
border-radius: 50%;
animation: blinking-dots 1s linear infinite;
}
}
.right-sidebar.build-sidebar { .right-sidebar.build-sidebar {
padding: $gl-padding 0; padding: $gl-padding 0;
...@@ -390,6 +375,10 @@ ...@@ -390,6 +375,10 @@
.container-fluid.container-limited { .container-fluid.container-limited {
max-width: 100%; max-width: 100%;
} }
.content-wrapper {
padding-bottom: 6px;
}
} }
.build-detail-row { .build-detail-row {
......
...@@ -36,7 +36,6 @@ ...@@ -36,7 +36,6 @@
pre.commit-message { pre.commit-message {
background: none; background: none;
padding: 0; padding: 0;
margin: 0;
border: none; border: none;
margin: 20px 0; margin: 20px 0;
border-radius: 0; border-radius: 0;
......
...@@ -94,7 +94,6 @@ ...@@ -94,7 +94,6 @@
.old_line, .old_line,
.new_line { .new_line {
margin: 0; margin: 0;
padding: 0;
border: none; border: none;
padding: 0 5px; padding: 0 5px;
border-right: 1px solid; border-right: 1px solid;
......
...@@ -64,6 +64,10 @@ ...@@ -64,6 +64,10 @@
} }
} }
.btn .text-center {
display: inline;
}
.commit-title { .commit-title {
margin: 0; margin: 0;
} }
......
...@@ -204,7 +204,6 @@ ul.related-merge-requests > li { ...@@ -204,7 +204,6 @@ ul.related-merge-requests > li {
.dropdown-toggle { .dropdown-toggle {
.fa-caret-down { .fa-caret-down {
pointer-events: none; pointer-events: none;
margin-left: 0;
color: inherit; color: inherit;
margin-left: 0; margin-left: 0;
} }
......
...@@ -520,17 +520,13 @@ ...@@ -520,17 +520,13 @@
position: absolute; position: absolute;
border-top: 2px solid $border-color; border-top: 2px solid $border-color;
height: 1px; height: 1px;
top: 8px; top: 9px;
width: 8px; width: 8px;
left: 0; left: 0;
} }
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;
&::before {
top: 14px;
}
} }
} }
...@@ -539,7 +535,7 @@ ...@@ -539,7 +535,7 @@
width: 2px; width: 2px;
background: $border-color; background: $border-color;
position: absolute; position: absolute;
top: -5px; top: -9px;
} }
} }
......
...@@ -18,7 +18,7 @@ ul.notes { ...@@ -18,7 +18,7 @@ ul.notes {
margin-left: 55px; margin-left: 55px;
&.timeline-content-form { &.timeline-content-form {
@media (max-width: $screen-sm-max) { @include notes-media('max', $screen-sm-max) {
margin-left: 0; margin-left: 0;
} }
} }
...@@ -120,7 +120,7 @@ ul.notes { ...@@ -120,7 +120,7 @@ ul.notes {
.note-header { .note-header {
@media (max-width: $screen-xs-min) { @include notes-media('max', $screen-xs-min) {
.inline { .inline {
display: block; display: block;
} }
...@@ -152,7 +152,7 @@ ul.notes { ...@@ -152,7 +152,7 @@ ul.notes {
padding-left: 0; padding-left: 0;
clear: both; clear: both;
@media (min-width: $screen-sm-min) { @include notes-media('min', $screen-sm-min) {
margin-left: 65px; margin-left: 65px;
} }
...@@ -200,7 +200,7 @@ ul.notes { ...@@ -200,7 +200,7 @@ ul.notes {
} }
.timeline-content { .timeline-content {
@media (min-width: $screen-sm-min) { @include notes-media('min', $screen-sm-min) {
margin-left: 30px; margin-left: 30px;
} }
} }
...@@ -370,7 +370,7 @@ ul.notes { ...@@ -370,7 +370,7 @@ ul.notes {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@media (max-width: $screen-xs-max) { @include notes-media('max', $screen-xs-max) {
flex-flow: row wrap; flex-flow: row wrap;
} }
} }
...@@ -385,7 +385,7 @@ ul.notes { ...@@ -385,7 +385,7 @@ ul.notes {
} }
.note-header-author-name { .note-header-author-name {
@media (max-width: $screen-xs-max) { @include notes-media('max', $screen-xs-max) {
display: none; display: none;
} }
} }
...@@ -393,7 +393,7 @@ ul.notes { ...@@ -393,7 +393,7 @@ ul.notes {
.note-headline-light { .note-headline-light {
display: inline; display: inline;
@media (max-width: $screen-xs-min) { @include notes-media('max', $screen-xs-min) {
display: block; display: block;
} }
} }
...@@ -435,7 +435,7 @@ ul.notes { ...@@ -435,7 +435,7 @@ ul.notes {
margin-left: 10px; margin-left: 10px;
color: $gray-darkest; color: $gray-darkest;
@media (max-width: $screen-xs-max) { @include notes-media('max', $screen-xs-max) {
float: none; float: none;
margin-left: 0; margin-left: 0;
} }
...@@ -446,7 +446,7 @@ ul.notes { ...@@ -446,7 +446,7 @@ ul.notes {
} }
.discussion-actions { .discussion-actions {
@media (max-width: $screen-md-max) { @include notes-media('max', $screen-md-max) {
float: none; float: none;
margin-left: 0; margin-left: 0;
...@@ -460,7 +460,7 @@ ul.notes { ...@@ -460,7 +460,7 @@ ul.notes {
display: inline; display: inline;
line-height: 20px; line-height: 20px;
@media (min-width: $screen-sm-min) { @include notes-media('min', $screen-sm-min) {
margin-left: 10px; margin-left: 10px;
line-height: 24px; line-height: 24px;
} }
...@@ -550,13 +550,13 @@ ul.notes { ...@@ -550,13 +550,13 @@ ul.notes {
position: relative; position: relative;
top: -2px; top: -2px;
display: inline-block; display: inline-block;
padding-left: 4px; padding-left: 7px;
padding-right: 4px; padding-right: 7px;
color: $notes-role-color; color: $notes-role-color;
font-size: 12px; font-size: 12px;
line-height: 20px; line-height: 20px;
border: 1px solid $border-color; border: 1px solid $border-color;
border-radius: $border-radius-base; border-radius: $label-border-radius;
} }
...@@ -629,7 +629,7 @@ ul.notes { ...@@ -629,7 +629,7 @@ ul.notes {
} }
.line-resolve-all-container { .line-resolve-all-container {
@media (min-width: $screen-sm-min) { @include notes-media('min', $screen-sm-min) {
margin-right: 0; margin-right: 0;
padding-left: $gl-padding; padding-left: $gl-padding;
} }
...@@ -744,10 +744,6 @@ ul.notes { ...@@ -744,10 +744,6 @@ ul.notes {
// Merge request notes in diffs // Merge request notes in diffs
.diff-file { .diff-file {
// Diff is side by side
.notes_content.parallel .note-header .note-header-author-name {
display: block;
}
// Diff is inline // Diff is inline
.notes_content .note-header .note-headline-light { .notes_content .note-header .note-headline-light {
display: inline-block; display: inline-block;
......
...@@ -88,6 +88,10 @@ ...@@ -88,6 +88,10 @@
} }
} }
.btn .text-center {
display: inline;
}
.tooltip { .tooltip {
white-space: nowrap; white-space: nowrap;
} }
......
...@@ -247,7 +247,6 @@ ...@@ -247,7 +247,6 @@
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
line-height: 13px; line-height: 13px;
padding: $gl-vert-padding $gl-padding;
letter-spacing: .4px; letter-spacing: .4px;
padding: 6px 14px; padding: 6px 14px;
text-align: center; text-align: center;
......
class Admin::BuildsController < Admin::ApplicationController class Admin::JobsController < Admin::ApplicationController
def index def index
@scope = params[:scope] @scope = params[:scope]
@all_builds = Ci::Build @all_builds = Ci::Build
...@@ -20,6 +20,6 @@ class Admin::BuildsController < Admin::ApplicationController ...@@ -20,6 +20,6 @@ class Admin::BuildsController < Admin::ApplicationController
def cancel_all def cancel_all
Ci::Build.running_or_pending.each(&:cancel) Ci::Build.running_or_pending.each(&:cancel)
redirect_to admin_builds_path redirect_to admin_jobs_path
end end
end end
...@@ -9,7 +9,7 @@ class AutocompleteController < ApplicationController ...@@ -9,7 +9,7 @@ class AutocompleteController < ApplicationController
@users = @users.where.not(id: params[:skip_users]) if params[:skip_users].present? @users = @users.where.not(id: params[:skip_users]) if params[:skip_users].present?
@users = @users.active @users = @users.active
@users = @users.reorder(:name) @users = @users.reorder(:name)
@users = @users.page(params[:page]) @users = @users.page(params[:page]).per(params[:per_page])
if params[:todo_filter].present? && current_user if params[:todo_filter].present? && current_user
@users = @users.todo_authors(current_user.id, params[:todo_state_filter]) @users = @users.todo_authors(current_user.id, params[:todo_state_filter])
......
...@@ -14,7 +14,16 @@ module IssuableActions ...@@ -14,7 +14,16 @@ module IssuableActions
name = issuable.human_class_name name = issuable.human_class_name
flash[:notice] = "The #{name} was successfully deleted." flash[:notice] = "The #{name} was successfully deleted."
redirect_to polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class]) index_path = polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class])
respond_to do |format|
format.html { redirect_to index_path }
format.json do
render json: {
web_url: index_path
}
end
end
end end
def bulk_update def bulk_update
......
...@@ -18,7 +18,7 @@ module RendersBlob ...@@ -18,7 +18,7 @@ module RendersBlob
} }
end end
def override_max_blob_size(blob) def conditionally_expand_blob(blob)
blob.override_max_size! if params[:override_max_size] == 'true' blob.expand! if params[:expanded] == 'true'
end end
end end
...@@ -24,7 +24,7 @@ class DashboardController < Dashboard::ApplicationController ...@@ -24,7 +24,7 @@ class DashboardController < Dashboard::ApplicationController
def load_events def load_events
projects = projects =
if params[:filter] == "starred" if params[:filter] == "starred"
current_user.viewable_starred_projects ProjectsFinder.new(current_user: current_user, params: { starred: true }).execute
else else
current_user.authorized_projects current_user.authorized_projects
end end
......
...@@ -64,6 +64,8 @@ class GroupsController < Groups::ApplicationController ...@@ -64,6 +64,8 @@ class GroupsController < Groups::ApplicationController
end end
def subgroups def subgroups
return not_found unless Group.supports_nested_groups?
@nested_groups = GroupsFinder.new(current_user, parent: group).execute @nested_groups = GroupsFinder.new(current_user, parent: group).execute
@nested_groups = @nested_groups.search(params[:filter_groups]) if params[:filter_groups].present? @nested_groups = @nested_groups.search(params[:filter_groups]) if params[:filter_groups].present?
end end
......
...@@ -27,7 +27,7 @@ class Projects::ArtifactsController < Projects::ApplicationController ...@@ -27,7 +27,7 @@ class Projects::ArtifactsController < Projects::ApplicationController
def file def file
blob = @entry.blob blob = @entry.blob
override_max_blob_size(blob) conditionally_expand_blob(blob)
respond_to do |format| respond_to do |format|
format.html do format.html do
...@@ -46,7 +46,7 @@ class Projects::ArtifactsController < Projects::ApplicationController ...@@ -46,7 +46,7 @@ class Projects::ArtifactsController < Projects::ApplicationController
def keep def keep
build.keep_artifacts! build.keep_artifacts!
redirect_to namespace_project_build_path(project.namespace, project, build) redirect_to namespace_project_job_path(project.namespace, project, build)
end end
def latest_succeeded def latest_succeeded
...@@ -79,7 +79,7 @@ class Projects::ArtifactsController < Projects::ApplicationController ...@@ -79,7 +79,7 @@ class Projects::ArtifactsController < Projects::ApplicationController
end end
def build_from_id def build_from_id
project.builds.find_by(id: params[:build_id]) if params[:build_id] project.builds.find_by(id: params[:job_id]) if params[:job_id]
end end
def build_from_ref def build_from_ref
......
...@@ -35,7 +35,7 @@ class Projects::BlobController < Projects::ApplicationController ...@@ -35,7 +35,7 @@ class Projects::BlobController < Projects::ApplicationController
end end
def show def show
override_max_blob_size(@blob) conditionally_expand_blob(@blob)
respond_to do |format| respond_to do |format|
format.html do format.html do
......
class Projects::BuildArtifactsController < Projects::ApplicationController
include ExtractsPath
include RendersBlob
before_action :authorize_read_build!
before_action :extract_ref_name_and_path
before_action :validate_artifacts!
def download
redirect_to download_namespace_project_job_artifacts_path(project.namespace, project, job)
end
def browse
redirect_to browse_namespace_project_job_artifacts_path(project.namespace, project, job, path: params[:path])
end
def file
redirect_to file_namespace_project_job_artifacts_path(project.namespace, project, job, path: params[:path])
end
def raw
redirect_to raw_namespace_project_job_artifacts_path(project.namespace, project, job, path: params[:path])
end
def latest_succeeded
redirect_to latest_succeeded_namespace_project_artifacts_path(project.namespace, project, job, ref_name_and_path: params[:ref_name_and_path], job: params[:job])
end
private
def validate_artifacts!
render_404 unless job && job.artifacts?
end
def extract_ref_name_and_path
return unless params[:ref_name_and_path]
@ref_name, @path = extract_ref(params[:ref_name_and_path])
end
def job
@job ||= job_from_id || job_from_ref
end
def job_from_id
project.builds.find_by(id: params[:build_id]) if params[:build_id]
end
def job_from_ref
return unless @ref_name
jobs = project.latest_successful_builds_for(@ref_name)
jobs.find_by(name: params[:job])
end
end
class Projects::BuildsController < Projects::ApplicationController class Projects::BuildsController < Projects::ApplicationController
before_action :build, except: [:index, :cancel_all] before_action :authorize_read_build!
before_action :authorize_read_build!,
only: [:index, :show, :status, :raw, :trace]
before_action :authorize_update_build!,
except: [:index, :show, :status, :raw, :trace, :cancel_all]
layout 'project'
def index def index
@scope = params[:scope] redirect_to namespace_project_jobs_path(project.namespace, project)
@all_builds = project.builds.relevant
@builds = @all_builds.order('created_at DESC')
@builds =
case @scope
when 'pending'
@builds.pending.reverse_order
when 'running'
@builds.running.reverse_order
when 'finished'
@builds.finished
else
@builds
end
@builds = @builds.includes([
{ pipeline: :project },
:project,
:tags
])
@builds = @builds.page(params[:page]).per(30)
end
def cancel_all
return access_denied! unless can?(current_user, :update_build, project)
@project.builds.running_or_pending.each do |build|
build.cancel if can?(current_user, :update_build, build)
end
redirect_to namespace_project_builds_path(project.namespace, project)
end end
def show def show
@builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC') redirect_to namespace_project_job_path(project.namespace, project, job)
@builds = @builds.where("id not in (?)", @build.id)
@pipeline = @build.pipeline
end
def trace
build.trace.read do |stream|
respond_to do |format|
format.json do
result = {
id: @build.id, status: @build.status, complete: @build.complete?
}
if stream.valid?
stream.limit
state = params[:state].presence
trace = stream.html_with_state(state)
result.merge!(trace.to_h)
end
render json: result
end
end
end
end
def retry
return respond_422 unless @build.retryable?
build = Ci::Build.retry(@build, current_user)
redirect_to build_path(build)
end
def play
return respond_422 unless @build.playable?
build = @build.play(current_user)
redirect_to build_path(build)
end
def cancel
return respond_422 unless @build.cancelable?
@build.cancel
redirect_to build_path(@build)
end
def status
render json: BuildSerializer
.new(project: @project, current_user: @current_user)
.represent_status(@build)
end
def erase
if @build.erase(erased_by: current_user)
redirect_to namespace_project_build_path(project.namespace, project, @build),
notice: "Build has been successfully erased!"
else
respond_422
end
end end
def raw def raw
build.trace.read do |stream| redirect_to raw_namespace_project_job_path(project.namespace, project, job)
if stream.file?
send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline'
else
render_404
end
end
end end
private private
def authorize_update_build! def job
return access_denied! unless can?(current_user, :update_build, build) @job ||= project.builds.find(params[:id])
end
def build
@build ||= project.builds.find(params[:id])
.present(current_user: current_user)
end
def build_path(build)
namespace_project_build_path(build.project.namespace, build.project, build)
end end
end end
...@@ -148,10 +148,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -148,10 +148,7 @@ class Projects::IssuesController < Projects::ApplicationController
format.json do format.json do
if @issue.valid? if @issue.valid?
render json: @issue.to_json(methods: [:task_status, :task_status_short], render json: IssueSerializer.new.represent(@issue)
include: { milestone: {},
assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
labels: { methods: :text_color } })
else else
render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
end end
......
class Projects::JobsController < Projects::ApplicationController
before_action :build, except: [:index, :cancel_all]
before_action :authorize_read_build!,
only: [:index, :show, :status, :raw, :trace]
before_action :authorize_update_build!,
except: [:index, :show, :status, :raw, :trace, :cancel_all]
layout 'project'
def index
@scope = params[:scope]
@all_builds = project.builds.relevant
@builds = @all_builds.order('created_at DESC')
@builds =
case @scope
when 'pending'
@builds.pending.reverse_order
when 'running'
@builds.running.reverse_order
when 'finished'
@builds.finished
else
@builds
end
@builds = @builds.includes([
{ pipeline: :project },
:project,
:tags
])
@builds = @builds.page(params[:page]).per(30)
end
def cancel_all
return access_denied! unless can?(current_user, :update_build, project)
@project.builds.running_or_pending.each do |build|
build.cancel if can?(current_user, :update_build, build)
end
redirect_to namespace_project_jobs_path(project.namespace, project)
end
def show
@builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC')
@builds = @builds.where("id not in (?)", @build.id)
@pipeline = @build.pipeline
end
def trace
build.trace.read do |stream|
respond_to do |format|
format.json do
result = {
id: @build.id, status: @build.status, complete: @build.complete?
}
if stream.valid?
stream.limit
state = params[:state].presence
trace = stream.html_with_state(state)
result.merge!(trace.to_h)
end
render json: result
end
end
end
end
def retry
return respond_422 unless @build.retryable?
build = Ci::Build.retry(@build, current_user)
redirect_to build_path(build)
end
def play
return respond_422 unless @build.playable?
build = @build.play(current_user)
redirect_to build_path(build)
end
def cancel
return respond_422 unless @build.cancelable?
@build.cancel
redirect_to build_path(@build)
end
def status
render json: BuildSerializer
.new(project: @project, current_user: @current_user)
.represent_status(@build)
end
def erase
if @build.erase(erased_by: current_user)
redirect_to namespace_project_job_path(project.namespace, project, @build),
notice: "Build has been successfully erased!"
else
respond_422
end
end
def raw
build.trace.read do |stream|
if stream.file?
send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline'
else
render_404
end
end
end
private
def authorize_update_build!
return access_denied! unless can?(current_user, :update_build, build)
end
def build
@build ||= project.builds.find(params[:id])
.present(current_user: current_user)
end
def build_path(build)
namespace_project_job_path(build.project.namespace, build.project, build)
end
end
...@@ -58,7 +58,7 @@ class Projects::PipelinesController < Projects::ApplicationController ...@@ -58,7 +58,7 @@ class Projects::PipelinesController < Projects::ApplicationController
def create def create
@pipeline = Ci::CreatePipelineService @pipeline = Ci::CreatePipelineService
.new(project, current_user, create_params) .new(project, current_user, create_params)
.execute(ignore_skip_ci: true, save_on_errors: false) .execute(:web, ignore_skip_ci: true, save_on_errors: false)
if @pipeline.persisted? if @pipeline.persisted?
redirect_to namespace_project_pipeline_path(project.namespace, project, @pipeline) redirect_to namespace_project_pipeline_path(project.namespace, project, @pipeline)
......
...@@ -56,7 +56,7 @@ class Projects::SnippetsController < Projects::ApplicationController ...@@ -56,7 +56,7 @@ class Projects::SnippetsController < Projects::ApplicationController
def show def show
blob = @snippet.blob blob = @snippet.blob
override_max_blob_size(blob) conditionally_expand_blob(blob)
respond_to do |format| respond_to do |format|
format.html do format.html do
......
...@@ -58,7 +58,7 @@ class SnippetsController < ApplicationController ...@@ -58,7 +58,7 @@ class SnippetsController < ApplicationController
def show def show
blob = @snippet.blob blob = @snippet.blob
override_max_blob_size(blob) conditionally_expand_blob(blob)
@note = Note.new(noteable: @snippet) @note = Note.new(noteable: @snippet)
@noteable = @snippet @noteable = @snippet
......
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
# project_ids_relation: int[] - project ids to use # project_ids_relation: int[] - project ids to use
# params: # params:
# trending: boolean # trending: boolean
# owned: boolean
# non_public: boolean # non_public: boolean
# starred: boolean # starred: boolean
# sort: string # sort: string
...@@ -28,13 +29,17 @@ class ProjectsFinder < UnionFinder ...@@ -28,13 +29,17 @@ class ProjectsFinder < UnionFinder
def execute def execute
items = init_collection items = init_collection
items = by_ids(items) items = items.map do |item|
item = by_ids(item)
item = by_personal(item)
item = by_starred(item)
item = by_trending(item)
item = by_visibilty_level(item)
item = by_tags(item)
item = by_search(item)
by_archived(item)
end
items = union(items) items = union(items)
items = by_personal(items)
items = by_visibilty_level(items)
items = by_tags(items)
items = by_search(items)
items = by_archived(items)
sort(items) sort(items)
end end
...@@ -43,10 +48,8 @@ class ProjectsFinder < UnionFinder ...@@ -43,10 +48,8 @@ class ProjectsFinder < UnionFinder
def init_collection def init_collection
projects = [] projects = []
if params[:trending].present? if params[:owned].present?
projects << Project.trending projects << current_user.owned_projects if current_user
elsif params[:starred].present? && current_user
projects << current_user.viewable_starred_projects
else else
projects << current_user.authorized_projects if current_user projects << current_user.authorized_projects if current_user
projects << Project.unscoped.public_to_user(current_user) unless params[:non_public].present? projects << Project.unscoped.public_to_user(current_user) unless params[:non_public].present?
...@@ -56,7 +59,7 @@ class ProjectsFinder < UnionFinder ...@@ -56,7 +59,7 @@ class ProjectsFinder < UnionFinder
end end
def by_ids(items) def by_ids(items)
project_ids_relation ? items.map { |item| item.where(id: project_ids_relation) } : items project_ids_relation ? items.where(id: project_ids_relation) : items
end end
def union(items) def union(items)
...@@ -67,6 +70,14 @@ class ProjectsFinder < UnionFinder ...@@ -67,6 +70,14 @@ class ProjectsFinder < UnionFinder
(params[:personal].present? && current_user) ? items.personal(current_user) : items (params[:personal].present? && current_user) ? items.personal(current_user) : items
end end
def by_starred(items)
(params[:starred].present? && current_user) ? items.starred_by(current_user) : items
end
def by_trending(items)
params[:trending].present? ? items.trending : items
end
def by_visibilty_level(items) def by_visibilty_level(items)
params[:visibility_level].present? ? items.where(visibility_level: params[:visibility_level]) : items params[:visibility_level].present? ? items.where(visibility_level: params[:visibility_level]) : items
end end
......
...@@ -276,7 +276,7 @@ module ApplicationHelper ...@@ -276,7 +276,7 @@ module ApplicationHelper
end end
def show_user_callout? def show_user_callout?
cookies[:user_callout_dismissed] == 'true' cookies[:user_callout_dismissed].nil?
end end
def linkedin_url(user) def linkedin_url(user)
......
...@@ -120,7 +120,7 @@ module BlobHelper ...@@ -120,7 +120,7 @@ module BlobHelper
def blob_raw_url def blob_raw_url
if @build && @entry if @build && @entry
raw_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: @entry.path) raw_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path: @entry.path)
elsif @snippet elsif @snippet
if @snippet.project_id if @snippet.project_id
raw_namespace_project_snippet_path(@project.namespace, @project, @snippet) raw_namespace_project_snippet_path(@project.namespace, @project, @snippet)
...@@ -240,14 +240,10 @@ module BlobHelper ...@@ -240,14 +240,10 @@ module BlobHelper
def blob_render_error_reason(viewer) def blob_render_error_reason(viewer)
case viewer.render_error case viewer.render_error
when :collapsed
"it is larger than #{number_to_human_size(viewer.collapse_limit)}"
when :too_large when :too_large
max_size = "it is larger than #{number_to_human_size(viewer.size_limit)}"
if viewer.can_override_max_size?
viewer.overridable_max_size
else
viewer.max_size
end
"it is larger than #{number_to_human_size(max_size)}"
when :server_side_but_stored_externally when :server_side_but_stored_externally
case viewer.blob.external_storage case viewer.blob.external_storage
when :lfs when :lfs
...@@ -264,8 +260,8 @@ module BlobHelper ...@@ -264,8 +260,8 @@ module BlobHelper
error = viewer.render_error error = viewer.render_error
options = [] options = []
if error == :too_large && viewer.can_override_max_size? if error == :collapsed
options << link_to('load it anyway', url_for(params.merge(viewer: viewer.type, override_max_size: true, format: nil))) options << link_to('load it anyway', url_for(params.merge(viewer: viewer.type, expanded: true, format: nil)))
end end
# If the error is `:server_side_but_stored_externally`, the simple viewer will show the same error, # If the error is `:server_side_but_stored_externally`, the simple viewer will show the same error,
......
...@@ -2,7 +2,7 @@ module BuildsHelper ...@@ -2,7 +2,7 @@ module BuildsHelper
def build_summary(build, skip: false) def build_summary(build, skip: false)
if build.has_trace? if build.has_trace?
if skip if skip
link_to "View job trace", pipeline_build_url(build.pipeline, build) link_to "View job trace", pipeline_job_url(build.pipeline, build)
else else
build.trace.html(last_lines: 10).html_safe build.trace.html(last_lines: 10).html_safe
end end
...@@ -20,8 +20,8 @@ module BuildsHelper ...@@ -20,8 +20,8 @@ module BuildsHelper
def javascript_build_options def javascript_build_options
{ {
page_url: namespace_project_build_url(@project.namespace, @project, @build), page_url: namespace_project_job_url(@project.namespace, @project, @build),
build_url: namespace_project_build_url(@project.namespace, @project, @build, :json), build_url: namespace_project_job_url(@project.namespace, @project, @build, :json),
build_status: @build.status, build_status: @build.status,
build_stage: @build.stage, build_stage: @build.stage,
log_state: '' log_state: ''
...@@ -31,7 +31,7 @@ module BuildsHelper ...@@ -31,7 +31,7 @@ module BuildsHelper
def build_failed_issue_options def build_failed_issue_options
{ {
title: "Build Failed ##{@build.id}", title: "Build Failed ##{@build.id}",
description: namespace_project_build_url(@project.namespace, @project, @build) description: namespace_project_job_url(@project.namespace, @project, @build)
} }
end end
end end
...@@ -56,7 +56,7 @@ module ButtonHelper ...@@ -56,7 +56,7 @@ module ButtonHelper
content_tag (append_link ? :a : :span), protocol, content_tag (append_link ? :a : :span), protocol,
class: klass, class: klass,
href: (project.http_url_to_repo(current_user) if append_link), href: (project.http_url_to_repo if append_link),
data: { data: {
html: true, html: true,
placement: placement, placement: placement,
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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