Commit 5a4a0824 authored by Luke "Jared" Bennett's avatar Luke "Jared" Bennett

Merge remote-tracking branch 'origin/master' into fix-realtime-edited-text-for-issues-9-3

parents e591401b aea03d7c
...@@ -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)
......
...@@ -97,6 +97,7 @@ gem 'fog-google', '~> 0.5' ...@@ -97,6 +97,7 @@ gem 'fog-google', '~> 0.5'
gem 'fog-local', '~> 0.3' gem 'fog-local', '~> 0.3'
gem 'fog-openstack', '~> 0.1' gem 'fog-openstack', '~> 0.1'
gem 'fog-rackspace', '~> 0.1.1' gem 'fog-rackspace', '~> 0.1.1'
gem 'fog-aliyun', '~> 0.1.0'
# for Google storage # for Google storage
gem 'google-api-client', '~> 0.8.6' gem 'google-api-client', '~> 0.8.6'
...@@ -109,7 +110,7 @@ gem 'seed-fu', '~> 2.3.5' ...@@ -109,7 +110,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 +371,7 @@ gem 'sys-filesystem', '~> 1.1.6' ...@@ -370,3 +371,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,9 +206,18 @@ GEM ...@@ -208,9 +206,18 @@ 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
fog-aliyun (0.1.0)
fog-core (~> 1.27)
fog-json (~> 1.0)
ipaddress (~> 0.8)
xml-simple (~> 1.1)
fog-aws (0.13.0) fog-aws (0.13.0)
fog-core (~> 1.38) fog-core (~> 1.38)
fog-json (~> 1.0) fog-json (~> 1.0)
...@@ -895,7 +902,7 @@ DEPENDENCIES ...@@ -895,7 +902,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)
...@@ -909,6 +916,9 @@ DEPENDENCIES ...@@ -909,6 +916,9 @@ 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-aliyun (~> 0.1.0)
fog-aws (~> 0.9) fog-aws (~> 0.9)
fog-core (~> 1.44) fog-core (~> 1.44)
fog-google (~> 0.5) fog-google (~> 0.5)
......
...@@ -118,7 +118,7 @@ export default Vue.component('pipelines-table', { ...@@ -118,7 +118,7 @@ export default Vue.component('pipelines-table', {
eventHub.$on('refreshPipelines', this.fetchPipelines); eventHub.$on('refreshPipelines', this.fetchPipelines);
}, },
beforeDestroyed() { beforeDestroy() {
eventHub.$off('refreshPipelines'); eventHub.$off('refreshPipelines');
}, },
......
...@@ -16,6 +16,13 @@ const JumpToDiscussion = Vue.extend({ ...@@ -16,6 +16,13 @@ const JumpToDiscussion = Vue.extend({
}; };
}, },
computed: { computed: {
buttonText: function () {
if (this.discussionId) {
return 'Jump to next unresolved discussion';
} else {
return 'Jump to first unresolved discussion';
}
},
allResolved: function () { allResolved: function () {
return this.unresolvedDiscussionCount === 0; return this.unresolvedDiscussionCount === 0;
}, },
......
...@@ -118,7 +118,7 @@ import ShortcutsBlob from './shortcuts_blob'; ...@@ -118,7 +118,7 @@ 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':
......
...@@ -8,7 +8,7 @@ const Keyboard = function () { ...@@ -8,7 +8,7 @@ const Keyboard = function () {
var isUpArrow = false; var isUpArrow = false;
var isDownArrow = false; var isDownArrow = false;
var removeHighlight = function removeHighlight(list) { var removeHighlight = function removeHighlight(list) {
var itemElements = Array.prototype.slice.call(list.list.querySelectorAll('li:not(.divider)'), 0); var itemElements = Array.prototype.slice.call(list.list.querySelectorAll('li:not(.divider):not(.hidden)'), 0);
var listItems = []; var listItems = [];
for(var i = 0; i < itemElements.length; i++) { for(var i = 0; i < itemElements.length; i++) {
var listItem = itemElements[i]; var listItem = itemElements[i];
......
...@@ -63,6 +63,9 @@ const AjaxFilter = { ...@@ -63,6 +63,9 @@ const AjaxFilter = {
return AjaxCache.retrieve(url) return AjaxCache.retrieve(url)
.then((data) => { .then((data) => {
this._loadData(data, config); this._loadData(data, config);
if (config.onLoadingFinished) {
config.onLoadingFinished(data);
}
}) })
.catch(config.onError); .catch(config.onError);
}, },
......
...@@ -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');
}; };
......
<script> <script>
/* global Flash */ /* global Flash */
import Visibility from 'visibilityjs';
import EnvironmentsService from '../services/environments_service'; import EnvironmentsService from '../services/environments_service';
import environmentTable from './environments_table.vue'; import environmentTable from './environments_table.vue';
import EnvironmentsStore from '../stores/environments_store'; import EnvironmentsStore from '../stores/environments_store';
...@@ -7,6 +8,8 @@ import loadingIcon from '../../vue_shared/components/loading_icon.vue'; ...@@ -7,6 +8,8 @@ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import tablePagination from '../../vue_shared/components/table_pagination.vue'; import tablePagination from '../../vue_shared/components/table_pagination.vue';
import '../../lib/utils/common_utils'; import '../../lib/utils/common_utils';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import Poll from '../../lib/utils/poll';
import environmentsMixin from '../mixins/environments_mixin';
export default { export default {
...@@ -16,6 +19,10 @@ export default { ...@@ -16,6 +19,10 @@ export default {
loadingIcon, loadingIcon,
}, },
mixins: [
environmentsMixin,
],
data() { data() {
const environmentsData = document.querySelector('#environments-list-view').dataset; const environmentsData = document.querySelector('#environments-list-view').dataset;
const store = new EnvironmentsStore(); const store = new EnvironmentsStore();
...@@ -35,6 +42,7 @@ export default { ...@@ -35,6 +42,7 @@ export default {
projectStoppedEnvironmentsPath: environmentsData.projectStoppedEnvironmentsPath, projectStoppedEnvironmentsPath: environmentsData.projectStoppedEnvironmentsPath,
newEnvironmentPath: environmentsData.newEnvironmentPath, newEnvironmentPath: environmentsData.newEnvironmentPath,
helpPagePath: environmentsData.helpPagePath, helpPagePath: environmentsData.helpPagePath,
isMakingRequest: false,
// Pagination Properties, // Pagination Properties,
paginationInformation: {}, paginationInformation: {},
...@@ -65,17 +73,43 @@ export default { ...@@ -65,17 +73,43 @@ export default {
* Toggles loading property. * Toggles loading property.
*/ */
created() { created() {
const scope = gl.utils.getParameterByName('scope') || this.visibility;
const page = gl.utils.getParameterByName('page') || this.pageNumber;
this.service = new EnvironmentsService(this.endpoint); this.service = new EnvironmentsService(this.endpoint);
this.fetchEnvironments(); const poll = new Poll({
resource: this.service,
method: 'get',
data: { scope, page },
successCallback: this.successCallback,
errorCallback: this.errorCallback,
notificationCallback: (isMakingRequest) => {
this.isMakingRequest = isMakingRequest;
// We need to verify if any folder is open to also fecth it
this.openFolders = this.store.getOpenFolders();
},
});
if (!Visibility.hidden()) {
this.isLoading = true;
poll.makeRequest();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
poll.restart();
} else {
poll.stop();
}
});
eventHub.$on('refreshEnvironments', this.fetchEnvironments);
eventHub.$on('toggleFolder', this.toggleFolder); eventHub.$on('toggleFolder', this.toggleFolder);
eventHub.$on('postAction', this.postAction); eventHub.$on('postAction', this.postAction);
}, },
beforeDestroyed() { beforeDestroy() {
eventHub.$off('refreshEnvironments');
eventHub.$off('toggleFolder'); eventHub.$off('toggleFolder');
eventHub.$off('postAction'); eventHub.$off('postAction');
}, },
...@@ -104,29 +138,13 @@ export default { ...@@ -104,29 +138,13 @@ export default {
fetchEnvironments() { fetchEnvironments() {
const scope = gl.utils.getParameterByName('scope') || this.visibility; const scope = gl.utils.getParameterByName('scope') || this.visibility;
const pageNumber = gl.utils.getParameterByName('page') || this.pageNumber; const page = gl.utils.getParameterByName('page') || this.pageNumber;
this.isLoading = true; this.isLoading = true;
return this.service.get(scope, pageNumber) return this.service.get({ scope, page })
.then(resp => ({ .then(this.successCallback)
headers: resp.headers, .catch(this.errorCallback);
body: resp.json(),
}))
.then((response) => {
this.store.storeAvailableCount(response.body.available_count);
this.store.storeStoppedCount(response.body.stopped_count);
this.store.storeEnvironments(response.body.environments);
this.store.setPagination(response.headers);
})
.then(() => {
this.isLoading = false;
})
.catch(() => {
this.isLoading = false;
// eslint-disable-next-line no-new
new Flash('An error occurred while fetching the environments.');
});
}, },
fetchChildEnvironments(folder, folderUrl) { fetchChildEnvironments(folder, folderUrl) {
...@@ -146,9 +164,34 @@ export default { ...@@ -146,9 +164,34 @@ export default {
}, },
postAction(endpoint) { postAction(endpoint) {
if (!this.isMakingRequest) {
this.isLoading = true;
this.service.postAction(endpoint) this.service.postAction(endpoint)
.then(() => this.fetchEnvironments()) .then(() => this.fetchEnvironments())
.catch(() => new Flash('An error occured while making the request.')); .catch(() => new Flash('An error occured while making the request.'));
}
},
successCallback(resp) {
this.saveData(resp);
// If folders are open while polling we need to open them again
if (this.openFolders.length) {
this.openFolders.map((folder) => {
// TODO - Move this to the backend
const folderUrl = `${window.location.pathname}/folders/${folder.folderName}`;
this.store.updateFolder(folder, 'isOpen', true);
return this.fetchChildEnvironments(folder, folderUrl);
});
}
},
errorCallback() {
this.isLoading = false;
// eslint-disable-next-line no-new
new Flash('An error occurred while fetching the environments.');
}, },
}, },
}; };
......
<script> <script>
/* global Flash */ /* global Flash */
import Visibility from 'visibilityjs';
import EnvironmentsService from '../services/environments_service'; import EnvironmentsService from '../services/environments_service';
import environmentTable from '../components/environments_table.vue'; import environmentTable from '../components/environments_table.vue';
import EnvironmentsStore from '../stores/environments_store'; import EnvironmentsStore from '../stores/environments_store';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import tablePagination from '../../vue_shared/components/table_pagination.vue'; import tablePagination from '../../vue_shared/components/table_pagination.vue';
import Poll from '../../lib/utils/poll';
import eventHub from '../event_hub';
import environmentsMixin from '../mixins/environments_mixin';
import '../../lib/utils/common_utils'; import '../../lib/utils/common_utils';
import '../../vue_shared/vue_resource_interceptor';
export default { export default {
components: { components: {
...@@ -15,6 +18,10 @@ export default { ...@@ -15,6 +18,10 @@ export default {
loadingIcon, loadingIcon,
}, },
mixins: [
environmentsMixin,
],
data() { data() {
const environmentsData = document.querySelector('#environments-folder-list-view').dataset; const environmentsData = document.querySelector('#environments-folder-list-view').dataset;
const store = new EnvironmentsStore(); const store = new EnvironmentsStore();
...@@ -76,33 +83,39 @@ export default { ...@@ -76,33 +83,39 @@ export default {
*/ */
created() { created() {
const scope = gl.utils.getParameterByName('scope') || this.visibility; const scope = gl.utils.getParameterByName('scope') || this.visibility;
const pageNumber = gl.utils.getParameterByName('page') || this.pageNumber; const page = gl.utils.getParameterByName('page') || this.pageNumber;
const endpoint = `${this.endpoint}?scope=${scope}&page=${pageNumber}`; this.service = new EnvironmentsService(this.endpoint);
this.service = new EnvironmentsService(endpoint); const poll = new Poll({
resource: this.service,
method: 'get',
data: { scope, page },
successCallback: this.successCallback,
errorCallback: this.errorCallback,
notificationCallback: (isMakingRequest) => {
this.isMakingRequest = isMakingRequest;
},
});
if (!Visibility.hidden()) {
this.isLoading = true; this.isLoading = true;
poll.makeRequest();
return this.service.get() }
.then(resp => ({
headers: resp.headers, Visibility.change(() => {
body: resp.json(), if (!Visibility.hidden()) {
})) poll.restart();
.then((response) => { } else {
this.store.storeAvailableCount(response.body.available_count); poll.stop();
this.store.storeStoppedCount(response.body.stopped_count); }
this.store.storeEnvironments(response.body.environments);
this.store.setPagination(response.headers);
})
.then(() => {
this.isLoading = false;
})
.catch(() => {
this.isLoading = false;
// eslint-disable-next-line no-new
new Flash('An error occurred while fetching the environments.', 'alert');
}); });
eventHub.$on('postAction', this.postAction);
},
beforeDestroyed() {
eventHub.$off('postAction');
}, },
methods: { methods: {
...@@ -117,6 +130,37 @@ export default { ...@@ -117,6 +130,37 @@ export default {
gl.utils.visitUrl(param); gl.utils.visitUrl(param);
return param; return param;
}, },
fetchEnvironments() {
const scope = gl.utils.getParameterByName('scope') || this.visibility;
const page = gl.utils.getParameterByName('page') || this.pageNumber;
this.isLoading = true;
return this.service.get({ scope, page })
.then(this.successCallback)
.catch(this.errorCallback);
},
successCallback(resp) {
this.saveData(resp);
},
errorCallback() {
this.isLoading = false;
// eslint-disable-next-line no-new
new Flash('An error occurred while fetching the environments.');
},
postAction(endpoint) {
if (!this.isMakingRequest) {
this.isLoading = true;
this.service.postAction(endpoint)
.then(() => this.fetchEnvironments())
.catch(() => new Flash('An error occured while making the request.'));
}
},
}, },
}; };
</script> </script>
......
export default {
methods: {
saveData(resp) {
const response = {
headers: resp.headers,
body: resp.json(),
};
this.isLoading = false;
this.store.storeAvailableCount(response.body.available_count);
this.store.storeStoppedCount(response.body.stopped_count);
this.store.storeEnvironments(response.body.environments);
this.store.setPagination(response.headers);
},
},
};
...@@ -10,7 +10,8 @@ export default class EnvironmentsService { ...@@ -10,7 +10,8 @@ export default class EnvironmentsService {
this.folderResults = 3; this.folderResults = 3;
} }
get(scope, page) { get(options = {}) {
const { scope, page } = options;
return this.environments.get({ scope, page }); return this.environments.get({ scope, page });
} }
......
...@@ -153,4 +153,10 @@ export default class EnvironmentsStore { ...@@ -153,4 +153,10 @@ export default class EnvironmentsStore {
return updatedEnvironments; return updatedEnvironments;
} }
getOpenFolders() {
const environments = this.state.environments;
return environments.filter(env => env.isFolder && env.isOpen);
}
} }
...@@ -18,6 +18,9 @@ class DropdownUser extends gl.FilteredSearchDropdown { ...@@ -18,6 +18,9 @@ class DropdownUser extends gl.FilteredSearchDropdown {
}, },
searchValueFunction: this.getSearchInput.bind(this), searchValueFunction: this.getSearchInput.bind(this),
loadingTemplate: this.loadingTemplate, loadingTemplate: this.loadingTemplate,
onLoadingFinished: () => {
this.hideCurrentUser();
},
onError() { onError() {
/* eslint-disable no-new */ /* eslint-disable no-new */
new Flash('An error occured fetching the dropdown data.'); new Flash('An error occured fetching the dropdown data.');
...@@ -28,6 +31,11 @@ class DropdownUser extends gl.FilteredSearchDropdown { ...@@ -28,6 +31,11 @@ class DropdownUser extends gl.FilteredSearchDropdown {
this.tokenKeys = tokenKeys; this.tokenKeys = tokenKeys;
} }
hideCurrentUser() {
const currenUserItem = this.dropdown.querySelector('.js-current-user');
currenUserItem.classList.add('hidden');
}
itemClicked(e) { itemClicked(e) {
super.itemClicked(e, super.itemClicked(e,
selected => selected.querySelector('.dropdown-light-content').innerText.trim()); selected => selected.querySelector('.dropdown-light-content').innerText.trim());
......
...@@ -7,8 +7,21 @@ window.Flash = (function() { ...@@ -7,8 +7,21 @@ window.Flash = (function() {
return $(this).fadeOut(); return $(this).fadeOut();
}; };
function Flash(message, type, parent) { /**
var flash, textDiv; * Flash banner supports different types of Flash configurations
* along with ability to provide actionConfig which can be used to show
* additional action or link on banner next to message
*
* @param {String} message Flash message
* @param {String} type Type of Flash, it can be `notice` or `alert` (default)
* @param {Object} parent Reference to Parent element under which Flash needs to appear
* @param {Object} actionConfig Map of config to show action on banner
* @param {String} href URL to which action link should point (default '#')
* @param {String} title Title of action
* @param {Function} clickHandler Method to call when action is clicked on
*/
function Flash(message, type, parent, actionConfig) {
var flash, textDiv, actionLink;
if (type == null) { if (type == null) {
type = 'alert'; type = 'alert';
} }
...@@ -30,6 +43,23 @@ window.Flash = (function() { ...@@ -30,6 +43,23 @@ window.Flash = (function() {
text: message text: message
}); });
textDiv.appendTo(flash); textDiv.appendTo(flash);
if (actionConfig) {
const actionLinkConfig = {
class: 'flash-action',
href: actionConfig.href || '#',
text: actionConfig.title
};
if (!actionConfig.href) {
actionLinkConfig.role = 'button';
}
actionLink = $('<a/>', actionLinkConfig);
actionLink.appendTo(flash);
this.flashContainer.on('click', '.flash-action', actionConfig.clickHandler);
}
if (this.flashContainer.parent().hasClass('content-wrapper')) { if (this.flashContainer.parent().hasClass('content-wrapper')) {
textDiv.addClass('container-fluid container-limited'); textDiv.addClass('container-fluid container-limited');
} }
......
...@@ -31,11 +31,15 @@ class GlFieldErrors { ...@@ -31,11 +31,15 @@ class GlFieldErrors {
* and prevents disabling of invalid submit button by application.js */ * and prevents disabling of invalid submit button by application.js */
catchInvalidFormSubmit (event) { catchInvalidFormSubmit (event) {
const $form = $(event.currentTarget);
if (!$form.attr('novalidate')) {
if (!event.currentTarget.checkValidity()) { if (!event.currentTarget.checkValidity()) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
} }
} }
}
/* Public method for triggering validity updates manually */ /* Public method for triggering validity updates manually */
updateFormValidityState() { updateFormValidityState() {
......
/* eslint-disable no-new */
import IntegrationSettingsForm from './integration_settings_form';
$(() => {
const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
integrationSettingsForm.init();
});
/* global Flash */
export default class IntegrationSettingsForm {
constructor(formSelector) {
this.$form = $(formSelector);
// Form Metadata
this.canTestService = this.$form.data('can-test');
this.testEndPoint = this.$form.data('test-url');
// Form Child Elements
this.$serviceToggle = this.$form.find('#service_active');
this.$submitBtn = this.$form.find('button[type="submit"]');
this.$submitBtnLoader = this.$submitBtn.find('.js-btn-spinner');
this.$submitBtnLabel = this.$submitBtn.find('.js-btn-label');
}
init() {
// Initialize View
this.toggleServiceState(this.$serviceToggle.is(':checked'));
// Bind Event Listeners
this.$serviceToggle.on('change', e => this.handleServiceToggle(e));
this.$submitBtn.on('click', e => this.handleSettingsSave(e));
}
handleSettingsSave(e) {
// Check if Service is marked active, as if not marked active,
// We can skip testing it and directly go ahead to allow form to
// be submitted
if (!this.$serviceToggle.is(':checked')) {
return;
}
// Service was marked active so now we check;
// 1) If form contents are valid
// 2) If this service can be tested
// If both conditions are true, we override form submission
// and test the service using provided configuration.
if (this.$form.get(0).checkValidity() && this.canTestService) {
e.preventDefault();
this.testSettings(this.$form.serialize());
}
}
handleServiceToggle(e) {
this.toggleServiceState($(e.currentTarget).is(':checked'));
}
/**
* Change Form's validation enforcement based on service status (active/inactive)
*/
toggleServiceState(serviceActive) {
this.toggleSubmitBtnLabel(serviceActive);
if (serviceActive) {
this.$form.removeAttr('novalidate');
} else if (!this.$form.attr('novalidate')) {
this.$form.attr('novalidate', 'novalidate');
}
}
/**
* Toggle Submit button label based on Integration status and ability to test service
*/
toggleSubmitBtnLabel(serviceActive) {
let btnLabel = 'Save changes';
if (serviceActive && this.canTestService) {
btnLabel = 'Test settings and save changes';
}
this.$submitBtnLabel.text(btnLabel);
}
/**
* Toggle Submit button state based on provided boolean value of `saveTestActive`
* When enabled, it does two things, and reverts back when disabled
*
* 1. It shows load spinner on submit button
* 2. Makes submit button disabled
*/
toggleSubmitBtnState(saveTestActive) {
if (saveTestActive) {
this.$submitBtn.disable();
this.$submitBtnLoader.removeClass('hidden');
} else {
this.$submitBtn.enable();
this.$submitBtnLoader.addClass('hidden');
}
}
/* eslint-disable promise/catch-or-return, no-new */
/**
* Test Integration config
*/
testSettings(formData) {
this.toggleSubmitBtnState(true);
$.ajax({
type: 'PUT',
url: this.testEndPoint,
data: formData,
})
.done((res) => {
if (res.error) {
new Flash(res.message, null, null, {
title: 'Save anyway',
clickHandler: (e) => {
e.preventDefault();
this.$form.submit();
},
});
} else {
this.$form.submit();
}
})
.fail(() => {
new Flash('Something went wrong on our end.');
})
.always(() => {
this.toggleSubmitBtnState(false);
});
}
}
<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 editedComponent from './edited.vue'; import editedComponent from './edited.vue';
import formComponent from './form.vue';
import '../../lib/utils/url_utility';
export default { export default {
props: { props: {
...@@ -13,15 +17,27 @@ export default { ...@@ -13,15 +17,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,
}, },
...@@ -50,10 +66,40 @@ export default { ...@@ -50,10 +66,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,
updatedAt: this.updatedAt, updatedAt: this.updatedAt,
...@@ -64,25 +110,98 @@ export default { ...@@ -64,25 +110,98 @@ 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,
editedComponent, editedComponent,
formComponent,
}, },
computed: { methods: {
hasUpdated() { openForm() {
return !!this.state.updatedAt; 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);
...@@ -90,22 +209,46 @@ export default { ...@@ -90,22 +209,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"
...@@ -115,12 +258,13 @@ export default { ...@@ -115,12 +258,13 @@ export default {
:can-update="canUpdate" :can-update="canUpdate"
:description-html="state.descriptionHtml" :description-html="state.descriptionHtml"
:description-text="state.descriptionText" :description-text="state.descriptionText"
:updated-at="state.updatedAt"
:task-status="state.taskStatus" /> :task-status="state.taskStatus" />
<edited-component <edited-component
v-if="hasUpdated" v-if="hasUpdated"
:updated-at="state.updatedAt" :updated-at="state.updatedAt"
:updated-by-name="state.updatedByName" :updated-by-name="state.updatedByName"
:updated-by-path="state.updatedByPath" :updated-by-path="state.updatedByPath" />
/> </div>
</div> </div>
</template> </template>
...@@ -18,7 +18,8 @@ ...@@ -18,7 +18,8 @@
}, },
taskStatus: { taskStatus: {
type: String, type: String,
required: true, required: false,
default: '',
}, },
}, },
data() { data() {
...@@ -72,6 +73,7 @@ ...@@ -72,6 +73,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,
updatedAt,
updatedByName,
updatedByPath,
} = issuableElement.dataset;
return { return {
canUpdate: gl.utils.convertPermissionToBoolean(canUpdate), ...initialData,
endpoint,
issuableRef,
initialTitle: issuableTitleElement.innerHTML,
initialDescriptionHtml: issuableDescriptionElement ? issuableDescriptionElement.innerHTML : '',
initialDescriptionText: issuableDescriptionTextarea ? issuableDescriptionTextarea.textContent : '',
updatedAt,
updatedByName,
updatedByPath,
}; };
}, },
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,
updatedAt: this.updatedAt, updatedAt: this.updatedAt,
updatedByName: this.updatedByName, updatedByName: this.updatedByName,
updatedByPath: this.updatedByPath, updatedByPath: this.updatedByPath,
}, },
}); });
}, },
})); });
});
...@@ -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,
updatedAt, updatedAt,
...@@ -9,7 +10,7 @@ export default class Store { ...@@ -9,7 +10,7 @@ export default class Store {
}) { }) {
this.state = { this.state = {
titleHtml, titleHtml,
titleText: '', titleText,
descriptionHtml, descriptionHtml,
descriptionText, descriptionText,
taskStatus: '', taskStatus: '',
...@@ -17,6 +18,14 @@ export default class Store { ...@@ -17,6 +18,14 @@ export default class Store {
updatedByName, updatedByName,
updatedByPath, updatedByPath,
}; };
this.formState = {
title: '',
confidential: false,
description: '',
lockedWarningVisible: false,
move_to_project_id: 0,
updateLoading: false,
};
} }
updateState(data) { updateState(data) {
...@@ -29,4 +38,15 @@ export default class Store { ...@@ -29,4 +38,15 @@ export default class Store {
this.state.updatedByName = data.updated_by_name; this.state.updatedByName = data.updated_by_name;
this.state.updatedByPath = data.updated_by_path; this.state.updatedByPath = data.updated_by_path;
} }
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);
}
} }
...@@ -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);
......
...@@ -66,7 +66,8 @@ w.gl.utils.removeParamQueryString = function(url, param) { ...@@ -66,7 +66,8 @@ w.gl.utils.removeParamQueryString = function(url, param) {
})()).join('&'); })()).join('&');
}; };
w.gl.utils.removeParams = (params) => { w.gl.utils.removeParams = (params) => {
const url = new URL(window.location.href); const url = document.createElement('a');
url.href = window.location.href;
params.forEach((param) => { params.forEach((param) => {
url.search = w.gl.utils.removeParamQueryString(url.search, param); url.search = w.gl.utils.removeParamQueryString(url.search, param);
}); });
......
...@@ -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, '\\\\'));
}, },
}, },
}; };
......
<script>
import ciHeader from '../../vue_shared/components/header_ci_component.vue';
import eventHub from '../event_hub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
name: 'PipelineHeaderSection',
props: {
pipeline: {
type: Object,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
},
components: {
ciHeader,
loadingIcon,
},
data() {
return {
actions: this.getActions(),
};
},
computed: {
status() {
return this.pipeline.details && this.pipeline.details.status;
},
shouldRenderContent() {
return !this.isLoading && Object.keys(this.pipeline).length;
},
},
methods: {
postAction(action) {
const index = this.actions.indexOf(action);
this.$set(this.actions[index], 'isLoading', true);
eventHub.$emit('headerPostAction', action);
},
getActions() {
const actions = [];
if (this.pipeline.retry_path) {
actions.push({
label: 'Retry',
path: this.pipeline.retry_path,
cssClass: 'js-retry-button btn btn-inverted-secondary',
type: 'button',
isLoading: false,
});
}
if (this.pipeline.cancel_path) {
actions.push({
label: 'Cancel running',
path: this.pipeline.cancel_path,
cssClass: 'js-btn-cancel-pipeline btn btn-danger',
type: 'button',
isLoading: false,
});
}
return actions;
},
},
watch: {
pipeline() {
this.actions = this.getActions();
},
},
};
</script>
<template>
<div class="pipeline-header-container">
<ci-header
v-if="shouldRenderContent"
:status="status"
item-name="Pipeline"
:item-id="pipeline.id"
:time="pipeline.created_at"
:user="pipeline.user"
:actions="actions"
@actionClicked="postAction"
/>
<loading-icon
v-else
size="2"/>
</div>
</template>
...@@ -33,7 +33,7 @@ export default { ...@@ -33,7 +33,7 @@ export default {
<user-avatar-link <user-avatar-link
v-if="user" v-if="user"
class="js-pipeline-url-user" class="js-pipeline-url-user"
:link-href="pipeline.user.web_url" :link-href="pipeline.user.path"
:img-src="pipeline.user.avatar_url" :img-src="pipeline.user.avatar_url"
:tooltip-text="pipeline.user.name" :tooltip-text="pipeline.user.name"
/> />
......
/* global Flash */
import Vue from 'vue'; import Vue from 'vue';
import PipelinesMediator from './pipeline_details_mediatior'; import PipelinesMediator from './pipeline_details_mediatior';
import pipelineGraph from './components/graph/graph_component.vue'; import pipelineGraph from './components/graph/graph_component.vue';
import pipelineHeader from './components/header_component.vue';
import eventHub from './event_hub';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const dataset = document.querySelector('.js-pipeline-details-vue').dataset; const dataset = document.querySelector('.js-pipeline-details-vue').dataset;
...@@ -9,7 +13,8 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -9,7 +13,8 @@ document.addEventListener('DOMContentLoaded', () => {
mediator.fetchPipeline(); mediator.fetchPipeline();
const pipelineGraphApp = new Vue({ // eslint-disable-next-line
new Vue({
el: '#js-pipeline-graph-vue', el: '#js-pipeline-graph-vue',
data() { data() {
return { return {
...@@ -29,5 +34,37 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -29,5 +34,37 @@ document.addEventListener('DOMContentLoaded', () => {
}, },
}); });
return pipelineGraphApp; // eslint-disable-next-line
new Vue({
el: '#js-pipeline-header-vue',
data() {
return {
mediator,
};
},
components: {
pipelineHeader,
},
created() {
eventHub.$on('headerPostAction', this.postAction);
},
beforeDestroy() {
eventHub.$off('headerPostAction', this.postAction);
},
methods: {
postAction(action) {
this.mediator.service.postAction(action.path)
.then(() => this.mediator.refreshPipeline())
.catch(() => new Flash('An error occurred while making the request.'));
},
},
render(createElement) {
return createElement('pipeline-header', {
props: {
isLoading: this.mediator.state.isLoading,
pipeline: this.mediator.store.state.pipeline,
},
});
},
});
}); });
...@@ -26,6 +26,8 @@ export default class pipelinesMediator { ...@@ -26,6 +26,8 @@ export default class pipelinesMediator {
if (!Visibility.hidden()) { if (!Visibility.hidden()) {
this.state.isLoading = true; this.state.isLoading = true;
this.poll.makeRequest(); this.poll.makeRequest();
} else {
this.refreshPipeline();
} }
Visibility.change(() => { Visibility.change(() => {
...@@ -48,4 +50,10 @@ export default class pipelinesMediator { ...@@ -48,4 +50,10 @@ export default class pipelinesMediator {
this.state.isLoading = false; this.state.isLoading = false;
return new Flash('An error occurred while fetching the pipeline.'); return new Flash('An error occurred while fetching the pipeline.');
} }
refreshPipeline() {
this.service.getPipeline()
.then(response => this.successCallback(response))
.catch(() => this.errorCallback());
}
} }
...@@ -169,7 +169,7 @@ export default { ...@@ -169,7 +169,7 @@ export default {
eventHub.$on('refreshPipelines', this.fetchPipelines); eventHub.$on('refreshPipelines', this.fetchPipelines);
}, },
beforeDestroyed() { beforeDestroy() {
eventHub.$off('refreshPipelines'); eventHub.$off('refreshPipelines');
}, },
......
...@@ -11,4 +11,9 @@ export default class PipelineService { ...@@ -11,4 +11,9 @@ export default class PipelineService {
getPipeline() { getPipeline() {
return this.pipeline.get(); return this.pipeline.get();
} }
// eslint-disable-next-line
postAction(endpoint) {
return Vue.http.post(`${endpoint}.json`);
}
} }
...@@ -33,8 +33,6 @@ export default class PipelinesService { ...@@ -33,8 +33,6 @@ export default class PipelinesService {
/** /**
* Post request for all pipelines actions. * Post request for all pipelines actions.
* Endpoint content type needs to be:
* `Content-Type:application/x-www-form-urlencoded`
* *
* @param {String} endpoint * @param {String} endpoint
* @return {Promise} * @return {Promise}
......
...@@ -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 = {}) {
......
...@@ -91,7 +91,7 @@ export default { ...@@ -91,7 +91,7 @@ export default {
hasAuthor() { hasAuthor() {
return this.author && return this.author &&
this.author.avatar_url && this.author.avatar_url &&
this.author.web_url && this.author.path &&
this.author.username; this.author.username;
}, },
...@@ -140,7 +140,7 @@ export default { ...@@ -140,7 +140,7 @@ export default {
<user-avatar-link <user-avatar-link
v-if="hasAuthor" v-if="hasAuthor"
class="avatar-image-container" class="avatar-image-container"
:link-href="author.web_url" :link-href="author.path"
:img-src="author.avatar_url" :img-src="author.avatar_url"
:img-alt="userImageAltDescription" :img-alt="userImageAltDescription"
:tooltip-text="author.username" :tooltip-text="author.username"
......
<script> <script>
import ciIconBadge from './ci_badge_link.vue'; import ciIconBadge from './ci_badge_link.vue';
import loadingIcon from './loading_icon.vue';
import timeagoTooltip from './time_ago_tooltip.vue'; import timeagoTooltip from './time_ago_tooltip.vue';
import tooltipMixin from '../mixins/tooltip'; import tooltipMixin from '../mixins/tooltip';
import userAvatarLink from './user_avatar/user_avatar_link.vue'; import userAvatarImage from './user_avatar/user_avatar_image.vue';
/** /**
* Renders header component for job and pipeline page based on UI mockups * Renders header component for job and pipeline page based on UI mockups
...@@ -31,7 +32,8 @@ export default { ...@@ -31,7 +32,8 @@ export default {
}, },
user: { user: {
type: Object, type: Object,
required: true, required: false,
default: () => ({}),
}, },
actions: { actions: {
type: Array, type: Array,
...@@ -46,8 +48,9 @@ export default { ...@@ -46,8 +48,9 @@ export default {
components: { components: {
ciIconBadge, ciIconBadge,
loadingIcon,
timeagoTooltip, timeagoTooltip,
userAvatarLink, userAvatarImage,
}, },
computed: { computed: {
...@@ -58,13 +61,13 @@ export default { ...@@ -58,13 +61,13 @@ export default {
methods: { methods: {
onClickAction(action) { onClickAction(action) {
this.$emit('postAction', action); this.$emit('actionClicked', action);
}, },
}, },
}; };
</script> </script>
<template> <template>
<header class="page-content-header top-area"> <header class="page-content-header">
<section class="header-main-content"> <section class="header-main-content">
<ci-icon-badge :status="status" /> <ci-icon-badge :status="status" />
...@@ -79,21 +82,23 @@ export default { ...@@ -79,21 +82,23 @@ export default {
by by
<user-avatar-link <template v-if="user">
:link-href="user.web_url" <a
:href="user.path"
:title="user.email"
class="js-user-link commit-committer-link"
ref="tooltip">
<user-avatar-image
:img-src="user.avatar_url" :img-src="user.avatar_url"
:img-alt="userAvatarAltText" :img-alt="userAvatarAltText"
:tooltip-text="user.name" :tooltip-text="user.name"
:img-size="24" :img-size="24"
/> />
<a
:href="user.web_url"
:title="user.email"
class="js-user-link commit-committer-link"
ref="tooltip">
{{user.name}} {{user.name}}
</a> </a>
</template>
</section> </section>
<section <section
...@@ -111,11 +116,17 @@ export default { ...@@ -111,11 +116,17 @@ export default {
<button <button
v-else="action.type === 'button'" v-else="action.type === 'button'"
@click="onClickAction(action)" @click="onClickAction(action)"
:disabled="action.isLoading"
:class="action.cssClass" :class="action.cssClass"
type="button"> type="button">
{{action.label}} {{action.label}}
</button>
<i
v-show="action.isLoading"
class="fa fa-spin fa-spinner"
aria-hidden="true">
</i>
</button>
</template> </template>
</section> </section>
</header> </header>
......
<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>
...@@ -83,7 +83,7 @@ export default { ...@@ -83,7 +83,7 @@ export default {
} else { } else {
commitAuthorInformation = { commitAuthorInformation = {
avatar_url: this.pipeline.commit.author_gravatar_url, avatar_url: this.pipeline.commit.author_gravatar_url,
web_url: `mailto:${this.pipeline.commit.author_email}`, path: `mailto:${this.pipeline.commit.author_email}`,
username: this.pipeline.commit.author_name, username: this.pipeline.commit.author_name,
}; };
} }
......
...@@ -60,6 +60,12 @@ export default { ...@@ -60,6 +60,12 @@ export default {
avatarSizeClass() { avatarSizeClass() {
return `s${this.size}`; return `s${this.size}`;
}, },
// API response sends null when gravatar is disabled and
// we provide an empty string when we use it inside user avatar link.
// In both cases we should render the defaultAvatarUrl
imageSource() {
return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
},
}, },
}; };
</script> </script>
...@@ -68,7 +74,7 @@ export default { ...@@ -68,7 +74,7 @@ export default {
<img <img
class="avatar" class="avatar"
:class="[avatarSizeClass, cssClasses]" :class="[avatarSizeClass, cssClasses]"
:src="imgSrc" :src="imageSource"
:width="size" :width="size"
:height="size" :height="size"
:alt="imgAlt" :alt="imgAlt"
......
...@@ -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');
},
}; };
...@@ -85,7 +85,7 @@ ...@@ -85,7 +85,7 @@
} }
/** /**
* Blame file * Annotate file
*/ */
&.blame { &.blame {
table { table {
......
...@@ -475,4 +475,5 @@ ...@@ -475,4 +475,5 @@
.filter-dropdown-loading { .filter-dropdown-loading {
padding: 8px 16px; padding: 8px 16px;
text-align: center;
} }
...@@ -16,6 +16,22 @@ ...@@ -16,6 +16,22 @@
@extend .alert; @extend .alert;
@extend .alert-danger; @extend .alert-danger;
margin: 0; margin: 0;
.flash-text,
.flash-action {
display: inline-block;
}
a.flash-action {
margin-left: 5px;
text-decoration: none;
font-weight: normal;
border-bottom: 1px solid;
&:hover {
border-color: transparent;
}
}
} }
.flash-notice, .flash-notice,
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
padding: 0; padding: 0;
&::before { &::before {
@include notes-media('max', $screen-xs-max) { @include notes-media('max', $screen-xs-min) {
background: none; background: none;
} }
} }
...@@ -30,7 +30,7 @@ ...@@ -30,7 +30,7 @@
.timeline-entry-inner { .timeline-entry-inner {
position: relative; position: relative;
@include notes-media('max', $screen-xs-max) { @include notes-media('max', $screen-xs-min) {
.timeline-icon { .timeline-icon {
display: none; display: 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;
/* /*
......
...@@ -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;
} }
......
...@@ -984,3 +984,11 @@ ...@@ -984,3 +984,11 @@
width: 12px; width: 12px;
} }
} }
.pipeline-header-container {
min-height: 55px;
.text-center {
padding-top: 12px;
}
}
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
...@@ -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
......
...@@ -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
...@@ -15,6 +15,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController ...@@ -15,6 +15,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController
respond_to do |format| respond_to do |format|
format.html format.html
format.json do format.json do
Gitlab::PollingInterval.set_header(response, interval: 3_000)
render json: { render json: {
environments: EnvironmentSerializer environments: EnvironmentSerializer
.new(project: @project, current_user: @current_user) .new(project: @project, current_user: @current_user)
......
...@@ -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)
......
...@@ -4,6 +4,7 @@ class Projects::ServicesController < Projects::ApplicationController ...@@ -4,6 +4,7 @@ class Projects::ServicesController < Projects::ApplicationController
# Authorize # Authorize
before_action :authorize_admin_project! before_action :authorize_admin_project!
before_action :service, only: [:edit, :update, :test] before_action :service, only: [:edit, :update, :test]
before_action :update_service, only: [:update, :test]
respond_to :html respond_to :html
...@@ -13,36 +14,46 @@ class Projects::ServicesController < Projects::ApplicationController ...@@ -13,36 +14,46 @@ class Projects::ServicesController < Projects::ApplicationController
end end
def update def update
@service.assign_attributes(service_params[:service])
if @service.save(context: :manual_change) if @service.save(context: :manual_change)
redirect_to( redirect_to(namespace_project_settings_integrations_path(@project.namespace, @project), notice: success_message)
edit_namespace_project_service_path(@project.namespace, @project, @service.to_param),
notice: 'Successfully updated.'
)
else else
render 'edit' render 'edit'
end end
end end
def test def test
return render_404 unless @service.can_test? message = {}
if @service.can_test?
data = @service.test_data(project, current_user) data = @service.test_data(project, current_user)
outcome = @service.test(data) outcome = @service.test(data)
if outcome[:success] unless outcome[:success]
message = { notice: 'We sent a request to the provided URL' } message = { error: true, message: 'Test failed.', service_response: outcome[:result].to_s }
end
status = :ok
else else
error_message = "We tried to send a request to the provided URL but an error occurred" status = :not_found
error_message << ": #{outcome[:result]}" if outcome[:result].present?
message = { alert: error_message }
end end
redirect_back_or_default(options: message) render json: message, status: status
end end
private private
def success_message
if @service.active?
"#{@service.title} activated."
else
"#{@service.title} settings saved, but not activated."
end
end
def update_service
@service.assign_attributes(service_params[:service])
end
def service def service
@service ||= @project.find_or_initialize_service(params[:id]) @service ||= @project.find_or_initialize_service(params[:id])
end end
......
...@@ -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
......
...@@ -42,6 +42,7 @@ class Projects::VariablesController < Projects::ApplicationController ...@@ -42,6 +42,7 @@ class Projects::VariablesController < Projects::ApplicationController
private private
def project_params def project_params
params.require(:variable).permit([:id, :key, :value, :_destroy]) params.require(:variable)
.permit([:id, :key, :value, :protected, :_destroy])
end end
end end
...@@ -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)
......
...@@ -8,18 +8,28 @@ module AvatarsHelper ...@@ -8,18 +8,28 @@ module AvatarsHelper
})) }))
end end
def user_avatar(options = {}) def user_avatar_without_link(options = {})
avatar_size = options[:size] || 16 avatar_size = options[:size] || 16
user_name = options[:user].try(:name) || options[:user_name] user_name = options[:user].try(:name) || options[:user_name]
css_class = options[:css_class] || '' css_class = options[:css_class] || ''
avatar_url = options[:url] || avatar_icon(options[:user] || options[:user_email], avatar_size)
data_attributes = { container: 'body' }
if options[:lazy]
data_attributes[:src] = avatar_url
end
avatar = image_tag( image_tag(
avatar_icon(options[:user] || options[:user_email], avatar_size), options[:lazy] ? '' : avatar_url,
class: "avatar has-tooltip s#{avatar_size} #{css_class}", class: "avatar has-tooltip s#{avatar_size} #{css_class}",
alt: "#{user_name}'s avatar", alt: "#{user_name}'s avatar",
title: user_name, title: user_name,
data: { container: 'body' } data: data_attributes
) )
end
def user_avatar(options = {})
avatar = user_avatar_without_link(options)
if options[:user] if options[:user]
link_to(avatar, user_path(options[:user])) link_to(avatar, user_path(options[: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
...@@ -8,8 +8,8 @@ module DiffHelper ...@@ -8,8 +8,8 @@ module DiffHelper
[marked_old_line, marked_new_line] [marked_old_line, marked_new_line]
end end
def expand_all_diffs? def diffs_expanded?
params[:expand_all_diffs].present? params[:expanded].present?
end end
def diff_view def diff_view
...@@ -22,10 +22,10 @@ module DiffHelper ...@@ -22,10 +22,10 @@ module DiffHelper
end end
def diff_options def diff_options
options = { ignore_whitespace_change: hide_whitespace?, no_collapse: expand_all_diffs? } options = { ignore_whitespace_change: hide_whitespace?, expanded: diffs_expanded? }
if action_name == 'diff_for_path' if action_name == 'diff_for_path'
options[:no_collapse] = true options[:expanded] = true
options[:paths] = params.values_at(:old_path, :new_path) options[:paths] = params.values_at(:old_path, :new_path)
end end
...@@ -66,12 +66,12 @@ module DiffHelper ...@@ -66,12 +66,12 @@ module DiffHelper
discussions_left = discussions_right = nil discussions_left = discussions_right = nil
if left && (left.unchanged? || left.removed?) if left && (left.unchanged? || left.discussable?)
line_code = diff_file.line_code(left) line_code = diff_file.line_code(left)
discussions_left = @grouped_diff_discussions[line_code] discussions_left = @grouped_diff_discussions[line_code]
end end
if right && right.added? if right&.discussable?
line_code = diff_file.line_code(right) line_code = diff_file.line_code(right)
discussions_right = @grouped_diff_discussions[line_code] discussions_right = @grouped_diff_discussions[line_code]
end end
......
...@@ -50,8 +50,8 @@ module GitlabRoutingHelper ...@@ -50,8 +50,8 @@ module GitlabRoutingHelper
namespace_project_cycle_analytics_path(project.namespace, project, *args) namespace_project_cycle_analytics_path(project.namespace, project, *args)
end end
def project_builds_path(project, *args) def project_jobs_path(project, *args)
namespace_project_builds_path(project.namespace, project, *args) namespace_project_jobs_path(project.namespace, project, *args)
end end
def project_ref_path(project, ref_name, *args) def project_ref_path(project, ref_name, *args)
...@@ -110,8 +110,8 @@ module GitlabRoutingHelper ...@@ -110,8 +110,8 @@ module GitlabRoutingHelper
namespace_project_pipeline_url(pipeline.project.namespace, pipeline.project, pipeline.id, *args) namespace_project_pipeline_url(pipeline.project.namespace, pipeline.project, pipeline.id, *args)
end end
def pipeline_build_url(pipeline, build, *args) def pipeline_job_url(pipeline, build, *args)
namespace_project_build_url(pipeline.project.namespace, pipeline.project, build.id, *args) namespace_project_job_url(pipeline.project.namespace, pipeline.project, build.id, *args)
end end
def commits_url(entity, *args) def commits_url(entity, *args)
...@@ -215,13 +215,13 @@ module GitlabRoutingHelper ...@@ -215,13 +215,13 @@ module GitlabRoutingHelper
case action case action
when 'download' when 'download'
download_namespace_project_build_artifacts_path(*args) download_namespace_project_job_artifacts_path(*args)
when 'browse' when 'browse'
browse_namespace_project_build_artifacts_path(*args) browse_namespace_project_job_artifacts_path(*args)
when 'file' when 'file'
file_namespace_project_build_artifacts_path(*args) file_namespace_project_job_artifacts_path(*args)
when 'raw' when 'raw'
raw_namespace_project_build_artifacts_path(*args) raw_namespace_project_job_artifacts_path(*args)
end end
end end
......
...@@ -199,11 +199,24 @@ module IssuablesHelper ...@@ -199,11 +199,24 @@ module IssuablesHelper
issuable_filter_params.any? { |k| params.key?(k) } issuable_filter_params.any? { |k| params.key?(k) }
end end
def issuable_app_data(project, issue) def issuable_initial_data(issuable)
data = { data = {
endpoint: realtime_changes_namespace_project_issue_path(project.namespace, project, issue), endpoint: namespace_project_issue_path(@project.namespace, @project, issuable),
'can-update' => can?(current_user, :update_issue, issue).to_s, canUpdate: can?(current_user, :update_issue, issuable),
'issuable-ref' => issue.to_reference || '' canDestroy: can?(current_user, :destroy_issue, issuable),
canMove: current_user ? issuable.can_move?(current_user) : false,
issuableRef: issuable.to_reference,
isConfidential: issuable.confidential,
markdownPreviewUrl: preview_markdown_path(@project),
markdownDocs: help_page_path('user/markdown'),
projectsAutocompleteUrl: autocomplete_projects_path(project_id: @project.id),
issuableTemplates: issuable_templates(issuable),
projectPath: ref_project.path,
projectNamespace: ref_project.namespace.full_path,
initialTitleHtml: markdown_field(issuable, :title),
initialTitleText: issuable.title,
initialDescriptionHtml: markdown_field(issuable, :description),
initialDescriptionText: issuable.description
} }
data.merge(updated_at_by(issue)) data.merge(updated_at_by(issue))
...@@ -213,8 +226,8 @@ module IssuablesHelper ...@@ -213,8 +226,8 @@ module IssuablesHelper
return {} unless issuable.is_edited? return {} unless issuable.is_edited?
{ {
updated_at: issuable.updated_at.to_time.iso8601, updatedAt: issuable.updated_at.to_time.iso8601,
updated_by: { updatedBy: {
name: issuable.last_edited_by.name, name: issuable.last_edited_by.name,
path: user_path(issuable.last_edited_by) path: user_path(issuable.last_edited_by)
} }
......
...@@ -50,7 +50,7 @@ module NotesHelper ...@@ -50,7 +50,7 @@ module NotesHelper
def link_to_reply_discussion(discussion, line_type = nil) def link_to_reply_discussion(discussion, line_type = nil)
return unless current_user return unless current_user
data = { discussion_id: discussion.id, line_type: line_type } data = { discussion_id: discussion.reply_id, line_type: line_type }
button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button', button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button',
data: data, title: 'Add a reply' data: data, title: 'Add a reply'
......
...@@ -13,13 +13,13 @@ class ApplicationSetting < ActiveRecord::Base ...@@ -13,13 +13,13 @@ class ApplicationSetting < ActiveRecord::Base
[\r\n] # any number of newline characters [\r\n] # any number of newline characters
}x }x
serialize :restricted_visibility_levels serialize :restricted_visibility_levels # rubocop:disable Cop/ActiverecordSerialize
serialize :import_sources serialize :import_sources # rubocop:disable Cop/ActiverecordSerialize
serialize :disabled_oauth_sign_in_sources, Array serialize :disabled_oauth_sign_in_sources, Array # rubocop:disable Cop/ActiverecordSerialize
serialize :domain_whitelist, Array serialize :domain_whitelist, Array # rubocop:disable Cop/ActiverecordSerialize
serialize :domain_blacklist, Array serialize :domain_blacklist, Array # rubocop:disable Cop/ActiverecordSerialize
serialize :repository_storages serialize :repository_storages # rubocop:disable Cop/ActiverecordSerialize
serialize :sidekiq_throttling_queues, Array serialize :sidekiq_throttling_queues, Array # rubocop:disable Cop/ActiverecordSerialize
cache_markdown_field :sign_in_text cache_markdown_field :sign_in_text
cache_markdown_field :help_page_text cache_markdown_field :help_page_text
......
class AuditEvent < ActiveRecord::Base class AuditEvent < ActiveRecord::Base
serialize :details, Hash serialize :details, Hash # rubocop:disable Cop/ActiverecordSerialize
belongs_to :user, foreign_key: :author_id belongs_to :user, foreign_key: :author_id
......
...@@ -102,10 +102,6 @@ class Blob < SimpleDelegator ...@@ -102,10 +102,6 @@ class Blob < SimpleDelegator
raw_size == 0 raw_size == 0
end end
def too_large?
size && truncated?
end
def external_storage_error? def external_storage_error?
if external_storage == :lfs if external_storage == :lfs
!project&.lfs_enabled? !project&.lfs_enabled?
...@@ -160,7 +156,7 @@ class Blob < SimpleDelegator ...@@ -160,7 +156,7 @@ class Blob < SimpleDelegator
end end
def readable_text? def readable_text?
text? && !stored_externally? && !too_large? text? && !stored_externally? && !truncated?
end end
def simple_viewer def simple_viewer
...@@ -187,9 +183,9 @@ class Blob < SimpleDelegator ...@@ -187,9 +183,9 @@ class Blob < SimpleDelegator
rendered_as_text? && rich_viewer rendered_as_text? && rich_viewer
end end
def override_max_size! def expand!
simple_viewer&.override_max_size = true simple_viewer&.expanded = true
rich_viewer&.override_max_size = true rich_viewer&.expanded = true
end end
private private
......
...@@ -7,8 +7,8 @@ module BlobViewer ...@@ -7,8 +7,8 @@ module BlobViewer
included do included do
self.loading_partial_name = 'loading_auxiliary' self.loading_partial_name = 'loading_auxiliary'
self.type = :auxiliary self.type = :auxiliary
self.overridable_max_size = 100.kilobytes self.collapse_limit = 100.kilobytes
self.max_size = 100.kilobytes self.size_limit = 100.kilobytes
end end
def visible_to?(current_user) def visible_to?(current_user)
......
...@@ -2,14 +2,14 @@ module BlobViewer ...@@ -2,14 +2,14 @@ module BlobViewer
class Base class Base
PARTIAL_PATH_PREFIX = 'projects/blob/viewers'.freeze PARTIAL_PATH_PREFIX = 'projects/blob/viewers'.freeze
class_attribute :partial_name, :loading_partial_name, :type, :extensions, :file_types, :load_async, :binary, :switcher_icon, :switcher_title, :overridable_max_size, :max_size class_attribute :partial_name, :loading_partial_name, :type, :extensions, :file_types, :load_async, :binary, :switcher_icon, :switcher_title, :collapse_limit, :size_limit
self.loading_partial_name = 'loading' self.loading_partial_name = 'loading'
delegate :partial_path, :loading_partial_path, :rich?, :simple?, :text?, :binary?, to: :class delegate :partial_path, :loading_partial_path, :rich?, :simple?, :text?, :binary?, to: :class
attr_reader :blob attr_reader :blob
attr_accessor :override_max_size attr_accessor :expanded
delegate :project, to: :blob delegate :project, to: :blob
...@@ -61,24 +61,16 @@ module BlobViewer ...@@ -61,24 +61,16 @@ module BlobViewer
self.class.load_async? && render_error.nil? self.class.load_async? && render_error.nil?
end end
def exceeds_overridable_max_size? def collapsed?
overridable_max_size && blob.raw_size > overridable_max_size return @collapsed if defined?(@collapsed)
end
def exceeds_max_size?
max_size && blob.raw_size > max_size
end
def can_override_max_size? @collapsed = !expanded && collapse_limit && blob.raw_size > collapse_limit
exceeds_overridable_max_size? && !exceeds_max_size?
end end
def too_large? def too_large?
if override_max_size return @too_large if defined?(@too_large)
exceeds_max_size?
else @too_large = size_limit && blob.raw_size > size_limit
exceeds_overridable_max_size?
end
end end
# This method is used on the server side to check whether we can attempt to # This method is used on the server side to check whether we can attempt to
...@@ -95,6 +87,8 @@ module BlobViewer ...@@ -95,6 +87,8 @@ module BlobViewer
def render_error def render_error
if too_large? if too_large?
:too_large :too_large
elsif collapsed?
:collapsed
end end
end end
......
...@@ -4,8 +4,8 @@ module BlobViewer ...@@ -4,8 +4,8 @@ module BlobViewer
included do included do
self.load_async = false self.load_async = false
self.overridable_max_size = 10.megabytes self.collapse_limit = 10.megabytes
self.max_size = 50.megabytes self.size_limit = 50.megabytes
end end
end end
end end
...@@ -4,8 +4,8 @@ module BlobViewer ...@@ -4,8 +4,8 @@ module BlobViewer
included do included do
self.load_async = true self.load_async = true
self.overridable_max_size = 2.megabytes self.collapse_limit = 2.megabytes
self.max_size = 5.megabytes self.size_limit = 5.megabytes
end end
def prepare! def prepare!
......
...@@ -5,7 +5,7 @@ module BlobViewer ...@@ -5,7 +5,7 @@ module BlobViewer
self.partial_name = 'text' self.partial_name = 'text'
self.binary = false self.binary = false
self.overridable_max_size = 1.megabyte self.collapse_limit = 1.megabyte
self.max_size = 10.megabytes self.size_limit = 10.megabytes
end end
end end
...@@ -19,8 +19,8 @@ module Ci ...@@ -19,8 +19,8 @@ module Ci
) )
end end
serialize :options serialize :options # rubocop:disable Cop/ActiverecordSerialize
serialize :yaml_variables, Gitlab::Serializer::Ci::Variables serialize :yaml_variables, Gitlab::Serializer::Ci::Variables # rubocop:disable Cop/ActiverecordSerialize
delegate :name, to: :project, prefix: true delegate :name, to: :project, prefix: true
...@@ -51,6 +51,12 @@ module Ci ...@@ -51,6 +51,12 @@ module Ci
after_destroy :update_project_statistics after_destroy :update_project_statistics
class << self class << self
# This is needed for url_for to work,
# as the controller is JobsController
def model_name
ActiveModel::Name.new(self, nil, 'job')
end
def first_pending def first_pending
pending.unstarted.order('created_at ASC').first pending.unstarted.order('created_at ASC').first
end end
...@@ -185,7 +191,7 @@ module Ci ...@@ -185,7 +191,7 @@ module Ci
variables += project.deployment_variables if has_environment? variables += project.deployment_variables if has_environment?
variables += yaml_variables variables += yaml_variables
variables += user_variables variables += user_variables
variables += project.secret_variables variables += project.secret_variables_for(ref).map(&:to_runner_variable)
variables += trigger_request.user_variables if trigger_request variables += trigger_request.user_variables if trigger_request
variables variables
end end
...@@ -249,38 +255,6 @@ module Ci ...@@ -249,38 +255,6 @@ module Ci
Time.now - updated_at > 15.minutes.to_i Time.now - updated_at > 15.minutes.to_i
end end
##
# Deprecated
#
# This contains a hotfix for CI build data integrity, see #4246
#
# This method is used by `ArtifactUploader` to create a store_dir.
# Warning: Uploader uses it after AND before file has been stored.
#
# This method returns old path to artifacts only if it already exists.
#
def artifacts_path
# We need the project even if it's soft deleted, because whenever
# we're really deleting the project, we'll also delete the builds,
# and in order to delete the builds, we need to know where to find
# the artifacts, which is depending on the data of the project.
# We need to retain the project in this case.
the_project = project || unscoped_project
old = File.join(created_at.utc.strftime('%Y_%m'),
the_project.ci_id.to_s,
id.to_s)
old_store = File.join(ArtifactUploader.artifacts_path, old)
return old if the_project.ci_id && File.directory?(old_store)
File.join(
created_at.utc.strftime('%Y_%m'),
the_project.id.to_s,
id.to_s
)
end
def valid_token?(token) def valid_token?(token)
self.token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.token) self.token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.token)
end end
......
...@@ -30,6 +30,7 @@ module Ci ...@@ -30,6 +30,7 @@ module Ci
delegate :id, to: :project, prefix: true delegate :id, to: :project, prefix: true
validates :source, exclusion: { in: %w(unknown), unless: :importing? }, on: :create
validates :sha, presence: { unless: :importing? } validates :sha, presence: { unless: :importing? }
validates :ref, presence: { unless: :importing? } validates :ref, presence: { unless: :importing? }
validates :status, presence: { unless: :importing? } validates :status, presence: { unless: :importing? }
...@@ -37,6 +38,16 @@ module Ci ...@@ -37,6 +38,16 @@ module Ci
after_create :keep_around_commits, unless: :importing? after_create :keep_around_commits, unless: :importing?
enum source: {
unknown: nil,
push: 1,
web: 2,
trigger: 3,
schedule: 4,
api: 5,
external: 6
}
state_machine :status, initial: :created do state_machine :status, initial: :created do
event :enqueue do event :enqueue do
transition created: :pending transition created: :pending
...@@ -269,10 +280,6 @@ module Ci ...@@ -269,10 +280,6 @@ module Ci
commit.sha == sha commit.sha == sha
end end
def triggered?
trigger_requests.any?
end
def retried def retried
@retried ||= (statuses.order(id: :desc) - statuses.latest) @retried ||= (statuses.order(id: :desc) - statuses.latest)
end end
......
...@@ -24,6 +24,10 @@ module Ci ...@@ -24,6 +24,10 @@ module Ci
owner == current_user owner == current_user
end end
def own!(user)
update(owner: user)
end
def inactive? def inactive?
!active? !active?
end end
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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