Commit 0383b482 authored by Kamil Trzcinski's avatar Kamil Trzcinski

Merge remote-tracking branch 'origin/master' into zj-job-view-goes-real-time

parents b2465182 11852e16
...@@ -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'
......
...@@ -213,6 +213,11 @@ GEM ...@@ -213,6 +213,11 @@ GEM
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)
...@@ -913,6 +918,7 @@ DEPENDENCIES ...@@ -913,6 +918,7 @@ DEPENDENCIES
flay (~> 2.8.0) flay (~> 2.8.0)
flipper (~> 0.10.2) flipper (~> 0.10.2)
flipper-active_record (~> 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)
......
...@@ -64,7 +64,7 @@ window.Build = (function () { ...@@ -64,7 +64,7 @@ window.Build = (function () {
$(window) $(window)
.off('resize.build') .off('resize.build')
.on('resize.build', this.sidebarOnResize.bind(this)); .on('resize.build', _.throttle(this.sidebarOnResize.bind(this), 100));
this.updateArtifactRemoveDate(); this.updateArtifactRemoveDate();
...@@ -250,6 +250,7 @@ window.Build = (function () { ...@@ -250,6 +250,7 @@ window.Build = (function () {
Build.prototype.sidebarOnResize = function () { Build.prototype.sidebarOnResize = function () {
this.toggleSidebar(this.shouldHideSidebarForViewport()); this.toggleSidebar(this.shouldHideSidebarForViewport());
this.verifyTopPosition(); this.verifyTopPosition();
if (this.$scrollContainer.getNiceScroll(0)) { if (this.$scrollContainer.getNiceScroll(0)) {
......
...@@ -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;
}, },
......
...@@ -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);
}, },
......
<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());
......
...@@ -102,10 +102,13 @@ class DropdownUtils { ...@@ -102,10 +102,13 @@ class DropdownUtils {
if (token.classList.contains('js-visual-token')) { if (token.classList.contains('js-visual-token')) {
const name = token.querySelector('.name'); const name = token.querySelector('.name');
const value = token.querySelector('.value'); const value = token.querySelector('.value');
const valueContainer = token.querySelector('.value-container');
const symbol = value && value.dataset.symbol ? value.dataset.symbol : ''; const symbol = value && value.dataset.symbol ? value.dataset.symbol : '';
let valueText = ''; let valueText = '';
if (value && value.innerText) { if (valueContainer && valueContainer.dataset.originalValue) {
valueText = valueContainer.dataset.originalValue;
} else if (value && value.innerText) {
valueText = value.innerText; valueText = value.innerText;
} }
......
import AjaxCache from '~/lib/utils/ajax_cache'; import AjaxCache from '../lib/utils/ajax_cache';
import '~/flash'; /* global Flash */ import '../flash'; /* global Flash */
import FilteredSearchContainer from './container'; import FilteredSearchContainer from './container';
import UsersCache from '../lib/utils/users_cache';
class FilteredSearchVisualTokens { class FilteredSearchVisualTokens {
static getLastVisualTokenBeforeInput() { static getLastVisualTokenBeforeInput() {
...@@ -82,12 +83,42 @@ class FilteredSearchVisualTokens { ...@@ -82,12 +83,42 @@ class FilteredSearchVisualTokens {
.catch(() => new Flash('An error occurred while fetching label colors.')); .catch(() => new Flash('An error occurred while fetching label colors.'));
} }
static updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) {
if (tokenValue === 'none') {
return Promise.resolve();
}
const username = tokenValue.replace(/^@/, '');
return UsersCache.retrieve(username)
.then((user) => {
if (!user) {
return;
}
/* eslint-disable no-param-reassign */
tokenValueContainer.dataset.originalValue = tokenValue;
tokenValueElement.innerHTML = `
<img class="avatar s20" src="${user.avatar_url}" alt="${user.name}'s avatar">
${user.name}
`;
/* eslint-enable no-param-reassign */
})
// ignore error and leave username in the search bar
.catch(() => { });
}
static renderVisualTokenValue(parentElement, tokenName, tokenValue) { static renderVisualTokenValue(parentElement, tokenName, tokenValue) {
const tokenValueContainer = parentElement.querySelector('.value-container'); const tokenValueContainer = parentElement.querySelector('.value-container');
tokenValueContainer.querySelector('.value').innerText = tokenValue; const tokenValueElement = tokenValueContainer.querySelector('.value');
tokenValueElement.innerText = tokenValue;
if (tokenName.toLowerCase() === 'label') { const tokenType = tokenName.toLowerCase();
if (tokenType === 'label') {
FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue); FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue);
} else if ((tokenType === 'author') || (tokenType === 'assignee')) {
FilteredSearchVisualTokens.updateUserTokenAppearance(
tokenValueContainer, tokenValueElement, tokenValue,
);
} }
} }
...@@ -153,6 +184,12 @@ class FilteredSearchVisualTokens { ...@@ -153,6 +184,12 @@ class FilteredSearchVisualTokens {
if (!lastVisualToken) return ''; if (!lastVisualToken) return '';
const valueContainer = lastVisualToken.querySelector('.value-container');
const originalValue = valueContainer && valueContainer.dataset.originalValue;
if (originalValue) {
return originalValue;
}
const value = lastVisualToken.querySelector('.value'); const value = lastVisualToken.querySelector('.value');
const name = lastVisualToken.querySelector('.name'); const name = lastVisualToken.querySelector('.name');
...@@ -205,17 +242,28 @@ class FilteredSearchVisualTokens { ...@@ -205,17 +242,28 @@ class FilteredSearchVisualTokens {
const inputLi = input.parentElement; const inputLi = input.parentElement;
tokenContainer.replaceChild(inputLi, token); tokenContainer.replaceChild(inputLi, token);
const name = token.querySelector('.name'); const nameElement = token.querySelector('.name');
const value = token.querySelector('.value'); let value;
if (token.classList.contains('filtered-search-token')) {
FilteredSearchVisualTokens.addFilterVisualToken(nameElement.innerText);
const valueContainerElement = token.querySelector('.value-container');
value = valueContainerElement.dataset.originalValue;
if (!value) {
const valueElement = valueContainerElement.querySelector('.value');
value = valueElement.innerText;
}
}
if (token.classList.contains('filtered-search-token') && value) {
FilteredSearchVisualTokens.addFilterVisualToken(name.innerText);
input.value = value.innerText;
} else {
// token is a search term // token is a search term
input.value = name.innerText; if (!value) {
value = nameElement.innerText;
} }
input.value = value;
// Opens dropdown // Opens dropdown
const inputEvent = new Event('input'); const inputEvent = new Event('input');
input.dispatchEvent(inputEvent); input.dispatchEvent(inputEvent);
......
...@@ -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);
});
}
}
...@@ -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}
......
...@@ -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>
......
...@@ -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"
......
...@@ -85,7 +85,7 @@ ...@@ -85,7 +85,7 @@
} }
/** /**
* Blame file * Annotate file
*/ */
&.blame { &.blame {
table { table {
......
...@@ -90,6 +90,7 @@ ...@@ -90,6 +90,7 @@
.filtered-search-term { .filtered-search-term {
display: -webkit-flex; display: -webkit-flex;
display: flex; display: flex;
flex-shrink: 0;
margin-top: 5px; margin-top: 5px;
margin-bottom: 5px; margin-bottom: 5px;
...@@ -239,7 +240,7 @@ ...@@ -239,7 +240,7 @@
width: 35px; width: 35px;
background-color: $white-light; background-color: $white-light;
border: none; border: none;
position: absolute; position: static;
right: 0; right: 0;
height: 100%; height: 100%;
outline: none; outline: none;
......
...@@ -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;
} }
......
...@@ -984,3 +984,11 @@ ...@@ -984,3 +984,11 @@
width: 12px; width: 12px;
} }
} }
.pipeline-header-container {
min-height: 55px;
.text-center {
padding-top: 12px;
}
}
...@@ -15,9 +15,9 @@ class Admin::KeysController < Admin::ApplicationController ...@@ -15,9 +15,9 @@ class Admin::KeysController < Admin::ApplicationController
respond_to do |format| respond_to do |format|
if key.destroy if key.destroy
format.html { redirect_to [:admin, user], notice: 'User key was successfully removed.' } format.html { redirect_to keys_admin_user_path(user), notice: 'User key was successfully removed.' }
else else
format.html { redirect_to [:admin, user], alert: 'Failed to remove user key.' } format.html { redirect_to keys_admin_user_path(user), alert: 'Failed to remove user key.' }
end end
end end
end end
......
...@@ -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
...@@ -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
......
...@@ -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
......
...@@ -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)
......
...@@ -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
......
...@@ -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]))
......
...@@ -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,
......
...@@ -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,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
...@@ -191,7 +191,7 @@ module Ci ...@@ -191,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
...@@ -260,38 +260,6 @@ module Ci ...@@ -260,38 +260,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
......
...@@ -6,7 +6,7 @@ module Ci ...@@ -6,7 +6,7 @@ module Ci
belongs_to :pipeline, foreign_key: :commit_id belongs_to :pipeline, foreign_key: :commit_id
has_many :builds has_many :builds
serialize :variables serialize :variables # rubocop:disable Cop/ActiverecordSerialize
def user_variables def user_variables
return [] unless variables return [] unless variables
......
...@@ -12,11 +12,16 @@ module Ci ...@@ -12,11 +12,16 @@ module Ci
message: "can contain only letters, digits and '_'." } message: "can contain only letters, digits and '_'." }
scope :order_key_asc, -> { reorder(key: :asc) } scope :order_key_asc, -> { reorder(key: :asc) }
scope :unprotected, -> { where(protected: false) }
attr_encrypted :value, attr_encrypted :value,
mode: :per_attribute_iv_and_salt, mode: :per_attribute_iv_and_salt,
insecure_mode: true, insecure_mode: true,
key: Gitlab::Application.secrets.db_key_base, key: Gitlab::Application.secrets.db_key_base,
algorithm: 'aes-256-cbc' algorithm: 'aes-256-cbc'
def to_runner_variable
{ key: key, value: value, public: false }
end
end end
end end
...@@ -43,7 +43,12 @@ module Noteable ...@@ -43,7 +43,12 @@ module Noteable
end end
def resolvable_discussions def resolvable_discussions
@resolvable_discussions ||= discussion_notes.resolvable.discussions(self) @resolvable_discussions ||=
if defined?(@discussions)
@discussions.select(&:resolvable?)
else
discussion_notes.resolvable.discussions(self)
end
end end
def discussions_resolvable? def discussions_resolvable?
......
...@@ -12,6 +12,7 @@ class Deployment < ActiveRecord::Base ...@@ -12,6 +12,7 @@ class Deployment < ActiveRecord::Base
delegate :name, to: :environment, prefix: true delegate :name, to: :environment, prefix: true
after_create :create_ref after_create :create_ref
after_create :invalidate_cache
def commit def commit
project.commit(sha) project.commit(sha)
...@@ -33,6 +34,10 @@ class Deployment < ActiveRecord::Base ...@@ -33,6 +34,10 @@ class Deployment < ActiveRecord::Base
project.repository.create_ref(ref, ref_path) project.repository.create_ref(ref, ref_path)
end end
def invalidate_cache
environment.expire_etag_cache
end
def manual_actions def manual_actions
@manual_actions ||= deployable.try(:other_actions) @manual_actions ||= deployable.try(:other_actions)
end end
......
...@@ -10,6 +10,7 @@ class DiffDiscussion < Discussion ...@@ -10,6 +10,7 @@ class DiffDiscussion < Discussion
delegate :position, delegate :position,
:original_position, :original_position,
:change_position,
to: :first_note to: :first_note
......
...@@ -6,9 +6,9 @@ class DiffNote < Note ...@@ -6,9 +6,9 @@ class DiffNote < Note
NOTEABLE_TYPES = %w(MergeRequest Commit).freeze NOTEABLE_TYPES = %w(MergeRequest Commit).freeze
serialize :original_position, Gitlab::Diff::Position serialize :original_position, Gitlab::Diff::Position # rubocop:disable Cop/ActiverecordSerialize
serialize :position, Gitlab::Diff::Position serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiverecordSerialize
serialize :change_position, Gitlab::Diff::Position serialize :change_position, Gitlab::Diff::Position # rubocop:disable Cop/ActiverecordSerialize
validates :original_position, presence: true validates :original_position, presence: true
validates :position, presence: true validates :position, presence: true
...@@ -95,13 +95,21 @@ class DiffNote < Note ...@@ -95,13 +95,21 @@ class DiffNote < Note
return if active? return if active?
Notes::DiffPositionUpdateService.new( tracer = Gitlab::Diff::PositionTracer.new(
self.project, project: self.project,
nil,
old_diff_refs: self.position.diff_refs, old_diff_refs: self.position.diff_refs,
new_diff_refs: noteable.diff_refs, new_diff_refs: self.noteable.diff_refs,
paths: self.position.paths paths: self.position.paths
).execute(self) )
result = tracer.trace(self.position)
return unless result
if result[:outdated]
self.change_position = result[:position]
else
self.position = result[:position]
end
end end
def verify_supported def verify_supported
......
...@@ -21,7 +21,8 @@ class Discussion ...@@ -21,7 +21,8 @@ class Discussion
end end
def self.build_collection(notes, context_noteable = nil) def self.build_collection(notes, context_noteable = nil)
notes.group_by { |n| n.discussion_id(context_noteable) }.values.map { |notes| build(notes, context_noteable) } grouped_notes = notes.group_by { |n| n.discussion_id(context_noteable) }
grouped_notes.values.map { |notes| build(notes, context_noteable) }
end end
# Returns an alphanumeric discussion ID based on `build_discussion_id` # Returns an alphanumeric discussion ID based on `build_discussion_id`
...@@ -84,6 +85,12 @@ class Discussion ...@@ -84,6 +85,12 @@ class Discussion
first_note.discussion_id(context_noteable) first_note.discussion_id(context_noteable)
end end
def reply_id
# To reply to this discussion, we need the actual discussion_id from the database,
# not the potentially overwritten one based on the noteable.
first_note.discussion_id
end
alias_method :to_param, :id alias_method :to_param, :id
def diff_discussion? def diff_discussion?
......
...@@ -57,6 +57,10 @@ class Environment < ActiveRecord::Base ...@@ -57,6 +57,10 @@ class Environment < ActiveRecord::Base
state :available state :available
state :stopped state :stopped
after_transition do |environment|
environment.expire_etag_cache
end
end end
def predefined_variables def predefined_variables
...@@ -196,6 +200,18 @@ class Environment < ActiveRecord::Base ...@@ -196,6 +200,18 @@ class Environment < ActiveRecord::Base
[external_url, public_path].join('/') [external_url, public_path].join('/')
end end
def expire_etag_cache
Gitlab::EtagCaching::Store.new.tap do |store|
store.touch(etag_cache_key)
end
end
def etag_cache_key
Gitlab::Routing.url_helpers.namespace_project_environments_path(
project.namespace,
project)
end
private private
# Slugifying a name may remove the uniqueness guarantee afforded by it being # Slugifying a name may remove the uniqueness guarantee afforded by it being
......
...@@ -26,7 +26,7 @@ class Event < ActiveRecord::Base ...@@ -26,7 +26,7 @@ class Event < ActiveRecord::Base
belongs_to :target, polymorphic: true belongs_to :target, polymorphic: true
# For Hash only # For Hash only
serialize :data serialize :data # rubocop:disable Cop/ActiverecordSerialize
# Callbacks # Callbacks
after_create :reset_project_activity after_create :reset_project_activity
......
class WebHookLog < ActiveRecord::Base class WebHookLog < ActiveRecord::Base
belongs_to :web_hook belongs_to :web_hook
serialize :request_headers, Hash serialize :request_headers, Hash # rubocop:disable Cop/ActiverecordSerialize
serialize :request_data, Hash serialize :request_data, Hash # rubocop:disable Cop/ActiverecordSerialize
serialize :response_headers, Hash serialize :response_headers, Hash # rubocop:disable Cop/ActiverecordSerialize
validates :web_hook, presence: true validates :web_hook, presence: true
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
class LegacyDiffNote < Note class LegacyDiffNote < Note
include NoteOnDiff include NoteOnDiff
serialize :st_diff serialize :st_diff # rubocop:disable Cop/ActiverecordSerialize
validates :line_code, presence: true, line_code: true validates :line_code, presence: true, line_code: true
......
...@@ -21,7 +21,7 @@ class MergeRequest < ActiveRecord::Base ...@@ -21,7 +21,7 @@ class MergeRequest < ActiveRecord::Base
belongs_to :assignee, class_name: "User" belongs_to :assignee, class_name: "User"
serialize :merge_params, Hash serialize :merge_params, Hash # rubocop:disable Cop/ActiverecordSerialize
after_create :ensure_merge_request_diff, unless: :importing? after_create :ensure_merge_request_diff, unless: :importing?
after_update :reload_diff_if_branch_changed after_update :reload_diff_if_branch_changed
...@@ -220,10 +220,10 @@ class MergeRequest < ActiveRecord::Base ...@@ -220,10 +220,10 @@ class MergeRequest < ActiveRecord::Base
def diffs(diff_options = {}) def diffs(diff_options = {})
if compare if compare
# When saving MR diffs, `no_collapse` is implicitly added (because we need # When saving MR diffs, `expanded` is implicitly added (because we need
# to save the entire contents to the DB), so add that here for # to save the entire contents to the DB), so add that here for
# consistency. # consistency.
compare.diffs(diff_options.merge(no_collapse: true)) compare.diffs(diff_options.merge(expanded: true))
else else
merge_request_diff.diffs(diff_options) merge_request_diff.diffs(diff_options)
end end
...@@ -421,7 +421,7 @@ class MergeRequest < ActiveRecord::Base ...@@ -421,7 +421,7 @@ class MergeRequest < ActiveRecord::Base
MergeRequests::MergeRequestDiffCacheService.new.execute(self) MergeRequests::MergeRequestDiffCacheService.new.execute(self)
new_diff_refs = self.diff_refs new_diff_refs = self.diff_refs
update_diff_notes_positions( update_diff_discussion_positions(
old_diff_refs: old_diff_refs, old_diff_refs: old_diff_refs,
new_diff_refs: new_diff_refs, new_diff_refs: new_diff_refs,
current_user: current_user current_user: current_user
...@@ -853,19 +853,18 @@ class MergeRequest < ActiveRecord::Base ...@@ -853,19 +853,18 @@ class MergeRequest < ActiveRecord::Base
diff_refs && diff_refs.complete? diff_refs && diff_refs.complete?
end end
def update_diff_notes_positions(old_diff_refs:, new_diff_refs:, current_user: nil) def update_diff_discussion_positions(old_diff_refs:, new_diff_refs:, current_user: nil)
return unless has_complete_diff_refs? return unless has_complete_diff_refs?
return if new_diff_refs == old_diff_refs return if new_diff_refs == old_diff_refs
active_diff_notes = self.notes.new_diff_notes.select do |note| active_diff_discussions = self.notes.new_diff_notes.discussions.select do |discussion|
note.active?(old_diff_refs) discussion.active?(old_diff_refs)
end end
return if active_diff_discussions.empty?
return if active_diff_notes.empty? paths = active_diff_discussions.flat_map { |n| n.diff_file.paths }.uniq
paths = active_diff_notes.flat_map { |n| n.diff_file.paths }.uniq service = Discussions::UpdateDiffPositionService.new(
service = Notes::DiffPositionUpdateService.new(
self.project, self.project,
current_user, current_user,
old_diff_refs: old_diff_refs, old_diff_refs: old_diff_refs,
...@@ -873,11 +872,8 @@ class MergeRequest < ActiveRecord::Base ...@@ -873,11 +872,8 @@ class MergeRequest < ActiveRecord::Base
paths: paths paths: paths
) )
transaction do active_diff_discussions.each do |discussion|
active_diff_notes.each do |note| service.execute(discussion)
service.execute(note)
Gitlab::Timeless.timeless(note, &:save)
end
end end
end end
......
class MergeRequestDiff < ActiveRecord::Base class MergeRequestDiff < ActiveRecord::Base
include Sortable include Sortable
include Importable include Importable
include Gitlab::Git::EncodingHelper include Gitlab::EncodingHelper
# Prevent store of diff if commits amount more then 500 # Prevent store of diff if commits amount more then 500
COMMITS_SAFE_SIZE = 100 COMMITS_SAFE_SIZE = 100
...@@ -11,8 +11,8 @@ class MergeRequestDiff < ActiveRecord::Base ...@@ -11,8 +11,8 @@ class MergeRequestDiff < ActiveRecord::Base
belongs_to :merge_request belongs_to :merge_request
serialize :st_commits serialize :st_commits # rubocop:disable Cop/ActiverecordSerialize
serialize :st_diffs serialize :st_diffs # rubocop:disable Cop/ActiverecordSerialize
state_machine :state, initial: :empty do state_machine :state, initial: :empty do
state :collected state :collected
......
...@@ -110,7 +110,7 @@ class Note < ActiveRecord::Base ...@@ -110,7 +110,7 @@ class Note < ActiveRecord::Base
end end
def discussions(context_noteable = nil) def discussions(context_noteable = nil)
Discussion.build_collection(fresh, context_noteable) Discussion.build_collection(all.includes(:noteable).fresh, context_noteable)
end end
def find_discussion(discussion_id) def find_discussion(discussion_id)
......
...@@ -3,7 +3,7 @@ class PersonalAccessToken < ActiveRecord::Base ...@@ -3,7 +3,7 @@ class PersonalAccessToken < ActiveRecord::Base
include TokenAuthenticatable include TokenAuthenticatable
add_authentication_token_field :token add_authentication_token_field :token
serialize :scopes, Array serialize :scopes, Array # rubocop:disable Cop/ActiverecordSerialize
belongs_to :user belongs_to :user
......
...@@ -1245,12 +1245,19 @@ class Project < ActiveRecord::Base ...@@ -1245,12 +1245,19 @@ class Project < ActiveRecord::Base
variables variables
end end
def secret_variables def secret_variables_for(ref)
variables.map do |variable| if protected_for?(ref)
{ key: variable.key, value: variable.value, public: false } variables
else
variables.unprotected
end end
end end
def protected_for?(ref)
ProtectedBranch.protected?(self, ref) ||
ProtectedTag.protected?(self, ref)
end
def deployment_variables def deployment_variables
return [] unless deployment_service return [] unless deployment_service
......
...@@ -10,7 +10,7 @@ class ProjectImportData < ActiveRecord::Base ...@@ -10,7 +10,7 @@ class ProjectImportData < ActiveRecord::Base
insecure_mode: true, insecure_mode: true,
algorithm: 'aes-256-cbc' algorithm: 'aes-256-cbc'
serialize :data, JSON serialize :data, JSON # rubocop:disable Cop/ActiverecordSerialize
validates :project, presence: true validates :project, presence: true
......
...@@ -34,7 +34,8 @@ http://app.asana.com/-/account_api' ...@@ -34,7 +34,8 @@ http://app.asana.com/-/account_api'
{ {
type: 'text', type: 'text',
name: 'api_key', name: 'api_key',
placeholder: 'User Personal Access Token. User must have access to task, all comments will be attributed to this user.' placeholder: 'User Personal Access Token. User must have access to task, all comments will be attributed to this user.',
required: true
}, },
{ {
type: 'text', type: 'text',
......
...@@ -18,7 +18,7 @@ class AssemblaService < Service ...@@ -18,7 +18,7 @@ class AssemblaService < Service
def fields def fields
[ [
{ type: 'text', name: 'token', placeholder: '' }, { type: 'text', name: 'token', placeholder: '', required: true },
{ type: 'text', name: 'subdomain', placeholder: '' } { type: 'text', name: 'subdomain', placeholder: '' }
] ]
end end
......
...@@ -47,9 +47,9 @@ class BambooService < CiService ...@@ -47,9 +47,9 @@ class BambooService < CiService
def fields def fields
[ [
{ type: 'text', name: 'bamboo_url', { type: 'text', name: 'bamboo_url',
placeholder: 'Bamboo root URL like https://bamboo.example.com' }, placeholder: 'Bamboo root URL like https://bamboo.example.com', required: true },
{ type: 'text', name: 'build_key', { type: 'text', name: 'build_key',
placeholder: 'Bamboo build plan key like KEY' }, placeholder: 'Bamboo build plan key like KEY', required: true },
{ type: 'text', name: 'username', { type: 'text', name: 'username',
placeholder: 'A user with API access, if applicable' }, placeholder: 'A user with API access, if applicable' },
{ type: 'password', name: 'password' } { type: 'password', name: 'password' }
......
...@@ -58,11 +58,11 @@ class BuildkiteService < CiService ...@@ -58,11 +58,11 @@ class BuildkiteService < CiService
[ [
{ type: 'text', { type: 'text',
name: 'token', name: 'token',
placeholder: 'Buildkite project GitLab token' }, placeholder: 'Buildkite project GitLab token', required: true },
{ type: 'text', { type: 'text',
name: 'project_url', name: 'project_url',
placeholder: "#{ENDPOINT}/example/project" }, placeholder: "#{ENDPOINT}/example/project", required: true },
{ type: 'checkbox', { type: 'checkbox',
name: 'enable_ssl_verification', name: 'enable_ssl_verification',
......
...@@ -18,7 +18,7 @@ class CampfireService < Service ...@@ -18,7 +18,7 @@ class CampfireService < Service
def fields def fields
[ [
{ type: 'text', name: 'token', placeholder: '' }, { type: 'text', name: 'token', placeholder: '', required: true },
{ type: 'text', name: 'subdomain', placeholder: '' }, { type: 'text', name: 'subdomain', placeholder: '' },
{ type: 'text', name: 'room', placeholder: '' } { type: 'text', name: 'room', placeholder: '' }
] ]
......
...@@ -21,10 +21,6 @@ class ChatNotificationService < Service ...@@ -21,10 +21,6 @@ class ChatNotificationService < Service
end end
end end
def can_test?
valid?
end
def self.supported_events def self.supported_events
%w[push issue confidential_issue merge_request note tag_push %w[push issue confidential_issue merge_request note tag_push
pipeline wiki_page] pipeline wiki_page]
...@@ -36,7 +32,7 @@ class ChatNotificationService < Service ...@@ -36,7 +32,7 @@ class ChatNotificationService < Service
def default_fields def default_fields
[ [
{ type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}", required: true },
{ type: 'text', name: 'username', placeholder: 'e.g. GitLab' }, { type: 'text', name: 'username', placeholder: 'e.g. GitLab' },
{ type: 'checkbox', name: 'notify_only_broken_pipelines' }, { type: 'checkbox', name: 'notify_only_broken_pipelines' },
{ type: 'checkbox', name: 'notify_only_default_branch' } { type: 'checkbox', name: 'notify_only_default_branch' }
......
...@@ -31,9 +31,9 @@ class CustomIssueTrackerService < IssueTrackerService ...@@ -31,9 +31,9 @@ class CustomIssueTrackerService < IssueTrackerService
[ [
{ type: 'text', name: 'title', placeholder: title }, { type: 'text', name: 'title', placeholder: title },
{ type: 'text', name: 'description', placeholder: description }, { type: 'text', name: 'description', placeholder: description },
{ type: 'text', name: 'project_url', placeholder: 'Project url' }, { type: 'text', name: 'project_url', placeholder: 'Project url', required: true },
{ type: 'text', name: 'issues_url', placeholder: 'Issue url' }, { type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true },
{ type: 'text', name: 'new_issue_url', placeholder: 'New Issue url' } { type: 'text', name: 'new_issue_url', placeholder: 'New Issue url', required: true }
] ]
end end
end end
...@@ -30,4 +30,8 @@ class DeploymentService < Service ...@@ -30,4 +30,8 @@ class DeploymentService < Service
def terminals(environment) def terminals(environment)
raise NotImplementedError raise NotImplementedError
end end
def can_test?
false
end
end end
...@@ -93,8 +93,8 @@ class DroneCiService < CiService ...@@ -93,8 +93,8 @@ class DroneCiService < CiService
def fields def fields
[ [
{ type: 'text', name: 'token', placeholder: 'Drone CI project specific token' }, { type: 'text', name: 'token', placeholder: 'Drone CI project specific token', required: true },
{ type: 'text', name: 'drone_url', placeholder: 'http://drone.example.com' }, { type: 'text', name: 'drone_url', placeholder: 'http://drone.example.com', required: true },
{ type: 'checkbox', name: 'enable_ssl_verification', title: "Enable SSL verification" } { type: 'checkbox', name: 'enable_ssl_verification', title: "Enable SSL verification" }
] ]
end end
......
...@@ -19,7 +19,7 @@ class ExternalWikiService < Service ...@@ -19,7 +19,7 @@ class ExternalWikiService < Service
def fields def fields
[ [
{ type: 'text', name: 'external_wiki_url', placeholder: 'The URL of the external Wiki' } { type: 'text', name: 'external_wiki_url', placeholder: 'The URL of the external Wiki', required: true }
] ]
end end
......
...@@ -18,7 +18,7 @@ class FlowdockService < Service ...@@ -18,7 +18,7 @@ class FlowdockService < Service
def fields def fields
[ [
{ type: 'text', name: 'token', placeholder: 'Flowdock Git source token' } { type: 'text', name: 'token', placeholder: 'Flowdock Git source token', required: true }
] ]
end end
......
...@@ -18,8 +18,8 @@ class GemnasiumService < Service ...@@ -18,8 +18,8 @@ class GemnasiumService < Service
def fields def fields
[ [
{ type: 'text', name: 'api_key', placeholder: 'Your personal API KEY on gemnasium.com ' }, { type: 'text', name: 'api_key', placeholder: 'Your personal API KEY on gemnasium.com ', required: true },
{ type: 'text', name: 'token', placeholder: 'The project\'s slug on gemnasium.com' } { type: 'text', name: 'token', placeholder: 'The project\'s slug on gemnasium.com', required: true }
] ]
end end
......
...@@ -33,7 +33,7 @@ class HipchatService < Service ...@@ -33,7 +33,7 @@ class HipchatService < Service
def fields def fields
[ [
{ type: 'text', name: 'token', placeholder: 'Room token' }, { type: 'text', name: 'token', placeholder: 'Room token', required: true },
{ type: 'text', name: 'room', placeholder: 'Room name or ID' }, { type: 'text', name: 'room', placeholder: 'Room name or ID' },
{ type: 'checkbox', name: 'notify' }, { type: 'checkbox', name: 'notify' },
{ type: 'select', name: 'color', choices: %w(yellow red green purple gray random) }, { type: 'select', name: 'color', choices: %w(yellow red green purple gray random) },
......
...@@ -49,7 +49,7 @@ class IrkerService < Service ...@@ -49,7 +49,7 @@ class IrkerService < Service
help: 'A default IRC URI to prepend before each recipient (optional)', help: 'A default IRC URI to prepend before each recipient (optional)',
placeholder: 'irc://irc.network.net:6697/' }, placeholder: 'irc://irc.network.net:6697/' },
{ type: 'textarea', name: 'recipients', { type: 'textarea', name: 'recipients',
placeholder: 'Recipients/channels separated by whitespaces', placeholder: 'Recipients/channels separated by whitespaces', required: true,
help: 'Recipients have to be specified with a full URI: '\ help: 'Recipients have to be specified with a full URI: '\
'irc[s]://irc.network.net[:port]/#channel. Special cases: if '\ 'irc[s]://irc.network.net[:port]/#channel. Special cases: if '\
'you want the channel to be a nickname instead, append ",isnick" to ' \ 'you want the channel to be a nickname instead, append ",isnick" to ' \
......
...@@ -32,9 +32,9 @@ class IssueTrackerService < Service ...@@ -32,9 +32,9 @@ class IssueTrackerService < Service
def fields def fields
[ [
{ type: 'text', name: 'description', placeholder: description }, { type: 'text', name: 'description', placeholder: description },
{ type: 'text', name: 'project_url', placeholder: 'Project url' }, { type: 'text', name: 'project_url', placeholder: 'Project url', required: true },
{ type: 'text', name: 'issues_url', placeholder: 'Issue url' }, { type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true },
{ type: 'text', name: 'new_issue_url', placeholder: 'New Issue url' } { type: 'text', name: 'new_issue_url', placeholder: 'New Issue url', required: true }
] ]
end end
......
...@@ -86,11 +86,11 @@ class JiraService < IssueTrackerService ...@@ -86,11 +86,11 @@ class JiraService < IssueTrackerService
def fields def fields
[ [
{ type: 'text', name: 'url', title: 'Web URL', placeholder: 'https://jira.example.com' }, { type: 'text', name: 'url', title: 'Web URL', placeholder: 'https://jira.example.com', required: true },
{ type: 'text', name: 'api_url', title: 'JIRA API URL', placeholder: 'If different from Web URL' }, { type: 'text', name: 'api_url', title: 'JIRA API URL', placeholder: 'If different from Web URL' },
{ type: 'text', name: 'project_key', placeholder: 'Project Key' }, { type: 'text', name: 'project_key', placeholder: 'Project Key', required: true },
{ type: 'text', name: 'username', placeholder: '' }, { type: 'text', name: 'username', placeholder: '', required: true },
{ type: 'password', name: 'password', placeholder: '' }, { type: 'password', name: 'password', placeholder: '', required: true },
{ type: 'text', name: 'jira_issue_transition_id', placeholder: '' } { type: 'text', name: 'jira_issue_transition_id', placeholder: '' }
] ]
end end
...@@ -175,10 +175,6 @@ class JiraService < IssueTrackerService ...@@ -175,10 +175,6 @@ class JiraService < IssueTrackerService
{ success: result.present?, result: result } { success: result.present?, result: result }
end end
def can_test?
username.present? && password.present?
end
# JIRA does not need test data. # JIRA does not need test data.
# We are requesting the project that belongs to the project key. # We are requesting the project that belongs to the project key.
def test_data(user = nil, project = nil) def test_data(user = nil, project = nil)
......
...@@ -21,7 +21,8 @@ class MockCiService < CiService ...@@ -21,7 +21,8 @@ class MockCiService < CiService
[ [
{ type: 'text', { type: 'text',
name: 'mock_service_url', name: 'mock_service_url',
placeholder: 'http://localhost:4004' } placeholder: 'http://localhost:4004',
required: true }
] ]
end end
...@@ -79,4 +80,8 @@ class MockCiService < CiService ...@@ -79,4 +80,8 @@ class MockCiService < CiService
:error :error
end end
end end
def can_test?
false
end
end end
...@@ -14,4 +14,8 @@ class MockMonitoringService < MonitoringService ...@@ -14,4 +14,8 @@ class MockMonitoringService < MonitoringService
def metrics(environment) def metrics(environment)
JSON.parse(File.read(Rails.root + 'spec/fixtures/metrics.json')) JSON.parse(File.read(Rails.root + 'spec/fixtures/metrics.json'))
end end
def can_test?
false
end
end end
...@@ -53,7 +53,8 @@ class PipelinesEmailService < Service ...@@ -53,7 +53,8 @@ class PipelinesEmailService < Service
[ [
{ type: 'textarea', { type: 'textarea',
name: 'recipients', name: 'recipients',
placeholder: 'Emails separated by comma' }, placeholder: 'Emails separated by comma',
required: true },
{ type: 'checkbox', { type: 'checkbox',
name: 'notify_only_broken_pipelines' } name: 'notify_only_broken_pipelines' }
] ]
......
...@@ -23,7 +23,8 @@ class PivotaltrackerService < Service ...@@ -23,7 +23,8 @@ class PivotaltrackerService < Service
{ {
type: 'text', type: 'text',
name: 'token', name: 'token',
placeholder: 'Pivotal Tracker API token.' placeholder: 'Pivotal Tracker API token.',
required: true
}, },
{ {
type: 'text', type: 'text',
......
...@@ -49,7 +49,8 @@ class PrometheusService < MonitoringService ...@@ -49,7 +49,8 @@ class PrometheusService < MonitoringService
type: 'text', type: 'text',
name: 'api_url', name: 'api_url',
title: 'API URL', title: 'API URL',
placeholder: 'Prometheus API Base URL, like http://prometheus.example.com/' placeholder: 'Prometheus API Base URL, like http://prometheus.example.com/',
required: true
} }
] ]
end end
......
...@@ -19,10 +19,10 @@ class PushoverService < Service ...@@ -19,10 +19,10 @@ class PushoverService < Service
def fields def fields
[ [
{ type: 'text', name: 'api_key', placeholder: 'Your application key' }, { type: 'text', name: 'api_key', placeholder: 'Your application key', required: true },
{ type: 'text', name: 'user_key', placeholder: 'Your user key' }, { type: 'text', name: 'user_key', placeholder: 'Your user key', required: true },
{ type: 'text', name: 'device', placeholder: 'Leave blank for all active devices' }, { type: 'text', name: 'device', placeholder: 'Leave blank for all active devices' },
{ type: 'select', name: 'priority', choices: { type: 'select', name: 'priority', required: true, choices:
[ [
['Lowest Priority', -2], ['Lowest Priority', -2],
['Low Priority', -1], ['Low Priority', -1],
......
...@@ -50,9 +50,9 @@ class TeamcityService < CiService ...@@ -50,9 +50,9 @@ class TeamcityService < CiService
def fields def fields
[ [
{ type: 'text', name: 'teamcity_url', { type: 'text', name: 'teamcity_url',
placeholder: 'TeamCity root URL like https://teamcity.example.com' }, placeholder: 'TeamCity root URL like https://teamcity.example.com', required: true },
{ type: 'text', name: 'build_type', { type: 'text', name: 'build_type',
placeholder: 'Build configuration ID' }, placeholder: 'Build configuration ID', required: true },
{ type: 'text', name: 'username', { type: 'text', name: 'username',
placeholder: 'A user with permissions to trigger a manual build' }, placeholder: 'A user with permissions to trigger a manual build' },
{ type: 'password', name: 'password' } { type: 'password', name: 'password' }
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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