Commit 387f2e04 authored by Dmitriy Zaporozhets's avatar Dmitriy Zaporozhets

Merge remote-tracking branch 'ee-com/master' into 3775-show-sast-results-in-mr-widget

Signed-off-by: default avatarDmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>
parents 5e11a314 0ddc1f7c
......@@ -241,7 +241,7 @@ linters:
# Numeric values should not contain unnecessary fractional portions.
UnnecessaryMantissa:
enabled: false
enabled: true
# Do not use parent selector references (&) when they would otherwise
# be unnecessary.
......
Please view this file on the master branch, on stable branches it's out of date.
## 10.1.4 (2017-11-14)
- No changes.
## 10.1.3 (2017-11-10)
- [FIXED] Fix: Failed to rebase MR from forked repo.
......
......@@ -2,6 +2,16 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
## 10.1.4 (2017-11-14)
### Fixed (4 changes)
- Don't try to create fork network memberships for forks with a missing source. !15366
- Formats bytes to human reabale number in registry table.
- Prevent error when authorizing an admin-created OAauth application without a set owner.
- Prevents position update for image diff notes.
## 10.1.3 (2017-11-10)
- [SECURITY] Prevent OAuth phishing attack by presenting detailed wording about app to user during authorization.
......
......@@ -414,7 +414,7 @@ group :ed25519 do
end
# Gitaly GRPC client
gem 'gitaly-proto', '~> 0.51.0', require: 'gitaly'
gem 'gitaly-proto', '~> 0.52.0', require: 'gitaly'
gem 'toml-rb', '~> 0.3.15', require: false
......
......@@ -298,7 +298,7 @@ GEM
po_to_json (>= 1.0.0)
rails (>= 3.2.0)
gherkin-ruby (0.3.2)
gitaly-proto (0.51.0)
gitaly-proto (0.52.0)
google-protobuf (~> 3.1)
grpc (~> 1.0)
github-linguist (4.7.6)
......@@ -1061,7 +1061,7 @@ DEPENDENCIES
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.2.0)
gitaly-proto (~> 0.51.0)
gitaly-proto (~> 0.52.0)
github-linguist (~> 4.7.0)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-license (~> 1.0)
......@@ -1224,4 +1224,4 @@ DEPENDENCIES
wikicloth (= 0.8.1)
BUNDLED WITH
1.15.4
1.16.0
......@@ -141,21 +141,29 @@ the stable branch are:
* Fixes for security issues
* New or updated translations (as long as they do not touch application code)
During the feature freeze all merge requests that are meant to go into the upcoming
release should have the correct milestone assigned _and_ have the label
~"Pick into Stable" set, so that release managers can find and pick them.
Merge requests without a milestone and this label will
not be merged into any stable branches.
Fixes marked like this will be shipped in the next RC for that release. Once
the final RC has been prepared ready for release on the 22nd, further fixes
marked ~"Pick into Stable" will go into a patch for that release.
If a merge request is to be picked into more than one release it will also need
the ~"Pick into Backports" label set to remind the release manager to change
the milestone after cherry-picking. As before, it should still have the
~"Pick into Stable" label and the milestone of the highest release it will be
picked into.
During the feature freeze all merge requests that are meant to go into the
upcoming release should have the correct milestone assigned _and_ the
`Pick into X.Y` label where `X.Y` is equal to the milestone, so that release
managers can find and pick them.
Merge requests without this label will not be picked into the stable release.
For example, if the upcoming release is `10.2.0` you will need to set the
`Pick into 10.2` label.
Fixes marked like this will be shipped in the next RC (before the 22nd), or the
next patch release.
If a merge request is to be picked into more than one release it will need one
`Pick into X.Y` label per release where the merge request should be back-ported
to.
For example, if the current patch release is `10.1.1` and a regression fix needs
to be backported down to the `9.5` release, you will need to assign it the
`10.1` milestone and the following labels:
- `Pick into 10.1`
- `Pick into 10.0`
- `Pick into 9.5`
### Asking for an exception
......
import { truncate } from './lib/utils/text_utility';
const MAX_MESSAGE_LENGTH = 500;
const MESSAGE_CELL_SELECTOR = '.abuse-reports .message';
......@@ -15,7 +17,7 @@ export default class AbuseReports {
if (reportMessage.length > MAX_MESSAGE_LENGTH) {
$messageCellElement.data('original-message', reportMessage);
$messageCellElement.data('message-truncated', 'true');
$messageCellElement.text(window.gl.text.truncate(reportMessage, MAX_MESSAGE_LENGTH));
$messageCellElement.text(truncate(reportMessage, MAX_MESSAGE_LENGTH));
}
}
......
......@@ -3,6 +3,7 @@
import Vue from 'vue';
import Flash from '../../../flash';
import './lists_dropdown';
import { pluralize } from '../../../lib/utils/text_utility';
const ModalStore = gl.issueBoards.ModalStore;
......@@ -21,7 +22,7 @@ gl.issueBoards.ModalFooter = Vue.extend({
submitText() {
const count = ModalStore.selectedCount();
return `Add ${count > 0 ? count : ''} ${gl.text.pluralize('issue', count)}`;
return `Add ${count > 0 ? count : ''} ${pluralize('issue', count)}`;
},
},
methods: {
......
......@@ -3,6 +3,8 @@
prefer-template, object-shorthand, prefer-arrow-callback */
/* global Pager */
import { pluralize } from './lib/utils/text_utility';
export default (function () {
const CommitsList = {};
......@@ -86,7 +88,7 @@ export default (function () {
// Update commits count in the previous commits header.
commitsCount += Number($(processedData).nextUntil('li.js-commit-header').first().find('li.commit').length);
$commitsHeadersLast.find('span.commits-count').text(`${commitsCount} ${gl.text.pluralize('commit', commitsCount)}`);
$commitsHeadersLast.find('span.commits-count').text(`${commitsCount} ${pluralize('commit', commitsCount)}`);
}
gl.utils.localTimeAgo($processedData.find('.js-timeago'));
......
/* eslint-disable func-names, prefer-arrow-callback */
import Api from './api';
import { humanize } from './lib/utils/text_utility';
export default class CreateLabelDropdown {
constructor($el, namespacePath, projectPath) {
......@@ -107,7 +108,7 @@ export default class CreateLabelDropdown {
errors = label.message;
} else {
errors = Object.keys(label.message).map(key =>
`${gl.text.humanize(key)} ${label.message[key].join(', ')}`,
`${humanize(key)} ${label.message[key].join(', ')}`,
).join('<br/>');
}
......
/* eslint-disable no-param-reassign */
import { __ } from '../locale';
import '../lib/utils/text_utility';
import { dasherize } from '../lib/utils/text_utility';
import DEFAULT_EVENT_OBJECTS from './default_event_objects';
const EMPTY_STAGE_TEXTS = {
......@@ -36,7 +36,7 @@ export default {
});
newData.stages.forEach((item) => {
const stageSlug = gl.text.dasherize(item.name.toLowerCase());
const stageSlug = dasherize(item.name.toLowerCase());
item.active = false;
item.isUserAllowed = data.permissions[stageSlug];
item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug];
......
......@@ -2,7 +2,7 @@
import Timeago from 'timeago.js';
import _ from 'underscore';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import '../../lib/utils/text_utility';
import { humanize } from '../../lib/utils/text_utility';
import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue';
import StopComponent from './environment_stop.vue';
......@@ -134,7 +134,7 @@ export default {
if (this.hasManualActions) {
return this.model.last_deployment.manual_actions.map((action) => {
const parsedAction = {
name: gl.text.humanize(action.name),
name: humanize(action.name),
play_path: action.play_path,
playable: action.playable,
};
......
......@@ -2,6 +2,7 @@
import axios from 'axios';
import SmartInterval from '~/smart_interval';
import { parseSeconds, stringifyTime } from './lib/utils/pretty_time';
import { addDelimiter } from './lib/utils/text_utility';
const healthyClass = 'geo-node-healthy';
const unhealthyClass = 'geo-node-unhealthy';
......@@ -52,7 +53,7 @@ class GeoNodeStatus {
static formatCountAndPercentage(count, total, percentage) {
if (count !== null || total != null) {
return `${gl.text.addDelimiter(count)}/${gl.text.addDelimiter(total)} (${percentage})`;
return `${addDelimiter(count)}/${addDelimiter(total)} (${percentage})`;
}
return notAvailable;
......@@ -60,7 +61,7 @@ class GeoNodeStatus {
static formatCount(count) {
if (count !== null) {
return gl.text.addDelimiter(count);
return addDelimiter(count);
}
return notAvailable;
......
......@@ -2,6 +2,7 @@
import GfmAutoComplete from './gfm_auto_complete';
import dropzoneInput from './dropzone_input';
import textUtils from './lib/utils/text_markdown';
export default class GLForm {
constructor(form, enableGFM = false) {
......@@ -46,7 +47,7 @@ export default class GLForm {
}
// form and textarea event listeners
this.addEventListeners();
gl.text.init(this.form);
textUtils.init(this.form);
// hide discard button
this.form.find('.js-note-discard').hide();
this.form.show();
......@@ -85,7 +86,7 @@ export default class GLForm {
clearEventListeners() {
this.textarea.off('focus');
this.textarea.off('blur');
gl.text.removeListeners(this.form);
textUtils.removeListeners(this.form);
}
addEventListeners() {
......
......@@ -14,7 +14,7 @@ document.addEventListener('DOMContentLoaded', () => {
render: createElement => createElement('related-issues-root', {
props: {
endpoint: relatedIssuesRootElement.dataset.endpoint,
canAddRelatedIssues: convertPermissionToBoolean(
canAdmin: convertPermissionToBoolean(
relatedIssuesRootElement.dataset.canAddRelatedIssues,
),
helpPath: relatedIssuesRootElement.dataset.helpPath,
......
......@@ -43,12 +43,15 @@ export default {
computed: {
inputPlaceholder() {
return 'Paste issue link or <#issue id>';
return `Paste issue link${this.allowAutoComplete ? ' or <#issue id>' : ''}`;
},
isSubmitButtonDisabled() {
return (this.inputValue.length === 0 && this.pendingReferences.length === 0)
|| this.isSubmitting;
},
allowAutoComplete() {
return Object.keys(this.autoCompleteSources).length > 0;
},
},
methods: {
......@@ -86,12 +89,14 @@ export default {
mounted() {
const $input = $(this.$refs.input);
this.gfmAutoComplete = new GfmAutoComplete(this.autoCompleteSources);
this.gfmAutoComplete.setup($input, {
issues: true,
});
$input.on('shown-issues.atwho', this.onAutoCompleteToggled.bind(this, true));
$input.on('hidden-issues.atwho', this.onAutoCompleteToggled.bind(this, false));
if (this.allowAutoComplete) {
this.gfmAutoComplete = new GfmAutoComplete(this.autoCompleteSources);
this.gfmAutoComplete.setup($input, {
issues: true,
});
$input.on('shown-issues.atwho', this.onAutoCompleteToggled.bind(this, true));
$input.on('hidden-issues.atwho', this.onAutoCompleteToggled.bind(this, false));
}
this.$refs.input.focus();
},
......@@ -114,15 +119,22 @@ export default {
role="button"
@click="onInputWrapperClick">
<ul class="add-issuable-form-input-token-list">
<!--
We need to ensure this key changes any time the pendingReferences array is updated
else two consecutive pending ref strings in an array with the same name will collide
and cause odd behavior when one is removed.
-->
<li
:key="reference"
:key="`${pendingReferences.length}-${reference}`"
v-for="(reference, index) in pendingReferences"
class="js-add-issuable-form-token-list-item add-issuable-form-token-list-item">
<issue-token
event-namespace="pendingIssuable"
:id-key="index"
:display-reference="reference"
:can-remove="true" />
:can-remove="true"
:is-condensed="true"
/>
</li>
<li class="add-issuable-form-input-list-item">
<input
......@@ -144,11 +156,10 @@ export default {
class="js-add-issuable-form-add-button btn btn-new pull-left"
:disabled="isSubmitButtonDisabled">
Add
<loadingIcon
<loading-icon
ref="loadingIcon"
v-if="isSubmitting"
:inline="true"
label="Submitting related issues" />
:inline="true" />
</button>
<button
type="button"
......
......@@ -4,7 +4,11 @@ import tooltip from '../../../vue_shared/directives/tooltip';
export default {
name: 'IssueToken',
data() {
return {
removeDisabled: false,
};
},
props: {
idKey: {
type: Number,
......@@ -39,6 +43,11 @@ export default {
required: false,
default: false,
},
isCondensed: {
type: Boolean,
required: false,
default: false,
},
},
directives: {
......@@ -47,11 +56,16 @@ export default {
computed: {
removeButtonLabel() {
return `Remove related issue ${this.displayReference}`;
return `Remove ${this.displayReference}`;
},
hasState() {
return this.state && this.state.length > 0;
},
stateTitle() {
if (this.isCondensed) return '';
return this.isOpen ? 'Open' : 'Closed';
},
isOpen() {
return this.state === 'opened';
},
......@@ -67,6 +81,12 @@ export default {
computedPath() {
return this.path.length ? this.path : null;
},
innerComponentType() {
return this.isCondensed ? 'span' : 'div';
},
issueTitle() {
return this.isCondensed ? this.title : '';
},
},
methods: {
......@@ -77,53 +97,81 @@ export default {
}
eventHub.$emit(`${namespacePrefix}removeRequest`, this.idKey);
this.removeDisabled = true;
},
},
};
</script>
<template>
<div class="issue-token">
<div :class="{
'issue-token': isCondensed,
'flex-row issue-info-container': !isCondensed,
}">
<component
v-tooltip
:is="this.computedLinkElementType"
ref="link"
class="issue-token-link"
:class="{
'issue-token-link': isCondensed,
'issue-main-info': !isCondensed,
}"
:href="computedPath"
:title="title"
data-placement="top">
<span
:title="issueTitle"
data-placement="top"
>
<component
:is="innerComponentType"
v-if="hasTitle"
ref="title"
class="js-issue-token-title"
:class="{
'issue-token-title issue-token-end': isCondensed,
'issue-title block-truncated': !isCondensed,
'issue-token-title-standalone': !canRemove
}">
<span class="issue-token-title-text">
{{ title }}
</span>
</component>
<component
:is="innerComponentType"
ref="reference"
class="issue-token-reference">
:class="{
'issue-token-reference': isCondensed,
'issuable-info': !isCondensed,
}">
<i
ref="stateIcon"
v-if="hasState"
v-tooltip
class="fa"
:class="{
'issue-token-state-icon-open fa-circle-o': isOpen,
'issue-token-state-icon-closed fa-minus': isClosed,
}"
:aria-label="state">
</i>
{{ displayReference }}
</span>
<span
v-if="hasTitle"
ref="title"
class="js-issue-token-title issue-token-title"
:class="{ 'issue-token-title-standalone': !canRemove }">
<span class="issue-token-title-text">
{{ title }}
</span>
</span>
:title="stateTitle"
:aria-label="state"
>
</i>{{ displayReference }}
</component>
</component>
<button
v-if="canRemove"
v-tooltip
ref="removeButton"
type="button"
class="js-issue-token-remove-button issue-token-remove-button"
class="js-issue-token-remove-button"
:class="{
'issue-token-remove-button': isCondensed,
'btn btn-default': !isCondensed
}"
:title="removeButtonLabel"
:aria-label="removeButtonLabel"
@click="onRemoveRequest">
:disabled="removeDisabled"
@click="onRemoveRequest"
>
<i
class="fa fa-times"
aria-hidden="true">
......
......@@ -24,7 +24,7 @@ export default {
required: false,
default: () => [],
},
canAddRelatedIssues: {
canAdmin: {
type: Boolean,
required: false,
default: false,
......@@ -54,6 +54,11 @@ export default {
required: false,
default: () => ({}),
},
title: {
type: String,
required: false,
default: 'Related issues',
},
},
directives: {
......@@ -100,7 +105,7 @@ export default {
class="panel-heading"
:class="{ 'panel-empty-heading': !this.hasBody }">
<h3 class="panel-title">
Related issues
{{ title }}
<a
v-if="hasHelpPath"
:href="helpPath">
......@@ -112,11 +117,11 @@ export default {
<div class="js-related-issues-header-issue-count related-issues-header-issue-count issue-count-badge">
<span
class="issue-count-badge-count"
:class="{ 'has-btn': this.canAddRelatedIssues }">
:class="{ 'has-btn': this.canAdmin }">
{{ badgeLabel }}
</span>
<button
v-if="canAddRelatedIssues"
v-if="canAdmin"
ref="issueCountBadgeAddButton"
type="button"
class="js-issue-count-badge-add-button issue-count-badge-add-button btn btn-sm btn-default"
......@@ -156,11 +161,11 @@ export default {
label="Fetching related issues" />
</div>
<ul
class="related-issues-token-list">
class="flex-list content-list issuable-list">
<li
:key="issue.id"
v-for="issue in relatedIssues"
class="js-related-issues-token-list-item related-issues-token-list-item">
class="js-related-issues-token-list-item">
<issue-token
event-namespace="relatedIssue"
:id-key="issue.id"
......@@ -168,7 +173,8 @@ export default {
:title="issue.title"
:path="issue.path"
:state="issue.state"
:can-remove="true" />
:can-remove="canAdmin"
/>
</li>
</ul>
</div>
......
......@@ -40,7 +40,7 @@ export default {
type: String,
required: true,
},
canAddRelatedIssues: {
canAdmin: {
type: Boolean,
required: false,
default: false,
......@@ -50,6 +50,16 @@ export default {
required: false,
default: '',
},
title: {
type: String,
required: false,
default: 'Related issues',
},
allowAutoComplete: {
type: Boolean,
required: false,
default: true,
},
},
data() {
......@@ -70,6 +80,7 @@ export default {
computed: {
autoCompleteSources() {
if (!this.allowAutoComplete) return {};
return gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources;
},
},
......@@ -86,13 +97,11 @@ export default {
})
.catch((res) => {
if (res && res.status !== 404) {
// eslint-disable-next-line no-new
new Flash('An error occurred while removing related issues.');
Flash('An error occurred while removing issues.');
}
});
} else {
// eslint-disable-next-line no-new
new Flash('We could not determine the path to remove the related issue');
Flash('We could not determine the path to remove the issue');
}
},
onToggleAddRelatedIssuesForm() {
......@@ -119,8 +128,11 @@ export default {
})
.catch((res) => {
this.isSubmitting = false;
// eslint-disable-next-line no-new
new Flash(res.data.message || 'We can\'t find an issue that matches what you are looking for.');
let errorMessage = 'We can\'t find an issue that matches what you are looking for.';
if (res.data && res.data.message) {
errorMessage = res.data.message;
}
Flash(errorMessage);
});
}
},
......@@ -137,7 +149,11 @@ export default {
this.store.setRelatedIssues(issues);
this.isFetching = false;
})
.catch(() => new Flash('An error occurred while fetching related issues.'));
.catch(() => {
this.store.setRelatedIssues([]);
this.isFetching = false;
Flash('An error occurred while fetching issues.');
});
},
onInput(newValue, caretPos) {
......@@ -211,9 +227,11 @@ export default {
:is-fetching="isFetching"
:is-submitting="isSubmitting"
:related-issues="state.relatedIssues"
:can-add-related-issues="canAddRelatedIssues"
:can-admin="canAdmin"
:pending-references="state.pendingReferences"
:is-form-visible="isFormVisible"
:input-value="inputValue"
:auto-complete-sources="autoCompleteSources" />
:auto-complete-sources="autoCompleteSources"
:title="title"
/>
</template>
......@@ -8,7 +8,7 @@ class RelatedIssuesStore {
};
}
setRelatedIssues(issues) {
setRelatedIssues(issues = []) {
this.state.relatedIssues = issues;
}
......
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, no-underscore-dangle, one-var-declaration-per-line, object-shorthand, no-unused-vars, no-new, comma-dangle, consistent-return, quotes, dot-notation, quote-props, prefer-arrow-callback, max-len */
import 'vendor/jquery.waitforimages';
import '~/lib/utils/text_utility';
import { addDelimiter } from './lib/utils/text_utility';
import Flash from './flash';
import TaskList from './task_list';
import CreateMergeRequestDropdown from './create_merge_request_dropdown';
......@@ -73,7 +73,7 @@ export default class Issue {
let numProjectIssues = Number(projectIssuesCounter.first().text().trim().replace(/[^\d]/, ''));
numProjectIssues = isClosed ? numProjectIssues - 1 : numProjectIssues + 1;
projectIssuesCounter.text(gl.text.addDelimiter(numProjectIssues));
projectIssuesCounter.text(addDelimiter(numProjectIssues));
if (this.createMergeRequestDropdown) {
if (isClosed) {
......
......@@ -29,6 +29,11 @@ export default {
required: false,
default: false,
},
showDeleteButton: {
type: Boolean,
required: false,
default: true,
},
issuableRef: {
type: String,
required: true,
......@@ -92,6 +97,11 @@ export default {
type: String,
required: true,
},
issuableType: {
type: String,
required: false,
default: 'issue',
},
},
data() {
const store = new Store({
......@@ -157,21 +167,21 @@ export default {
})
.catch(() => {
eventHub.$emit('close.form');
window.Flash('Error updating issue');
window.Flash(`Error updating ${this.issuableType}`);
});
},
deleteIssuable() {
this.service.deleteIssuable()
.then(res => res.json())
.then((data) => {
// Stop the poll so we don't get 404's with the issue not existing
// Stop the poll so we don't get 404's with the issuable not existing
this.poll.stop();
gl.utils.visitUrl(data.web_url);
})
.catch(() => {
eventHub.$emit('close.form');
window.Flash('Error deleting issue');
window.Flash(`Error deleting ${this.issuableType}`);
});
},
},
......@@ -223,6 +233,7 @@ export default {
:markdown-preview-path="markdownPreviewPath"
:project-path="projectPath"
:project-namespace="projectNamespace"
:show-delete-button="showDeleteButton"
/>
<div v-else>
<title-component
......
......@@ -13,6 +13,11 @@
type: Object,
required: true,
},
showDeleteButton: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
......@@ -23,6 +28,9 @@
isSubmitEnabled() {
return this.formState.title.trim() !== '';
},
shouldShowDeleteButton() {
return this.canDestroy && this.showDeleteButton;
},
},
methods: {
closeForm() {
......@@ -62,7 +70,7 @@
Cancel
</button>
<button
v-if="canDestroy"
v-if="shouldShowDeleteButton"
class="btn btn-danger pull-right append-right-default"
:class="{ disabled: deleteLoading }"
type="button"
......
......@@ -36,6 +36,11 @@
type: String,
required: true,
},
showDeleteButton: {
type: Boolean,
required: false,
default: true,
},
},
components: {
lockedWarning,
......@@ -81,6 +86,7 @@
:markdown-docs-path="markdownDocsPath" />
<edit-actions
:form-state="formState"
:can-destroy="canDestroy" />
:can-destroy="canDestroy"
:show-delete-button="showDeleteButton" />
</form>
</template>
......@@ -14,8 +14,8 @@ export default class Job {
this.state = this.options.logState;
this.buildStage = this.options.buildStage;
this.$document = $(document);
this.$window = $(window);
this.logBytes = 0;
this.hasBeenScrolled = false;
this.updateDropdown = this.updateDropdown.bind(this);
this.$buildTrace = $('#build-trace');
......@@ -54,23 +54,18 @@ export default class Job {
this.scrollThrottled = _.throttle(this.toggleScroll.bind(this), 100);
$(window)
this.$window
.off('scroll')
.on('scroll', () => {
const contentHeight = this.$buildTraceOutput.height();
if (contentHeight > this.windowSize) {
// means the user did not scroll, the content was updated.
this.windowSize = contentHeight;
} else {
// User scrolled
this.hasBeenScrolled = true;
if (!this.isScrolledToBottom()) {
this.toggleScrollAnimation(false);
} else if (this.isScrolledToBottom() && !this.isLogComplete) {
this.toggleScrollAnimation(true);
}
this.scrollThrottled();
});
$(window)
this.$window
.off('resize.build')
.on('resize.build', _.throttle(this.sidebarOnResize.bind(this), 100));
......@@ -99,14 +94,14 @@ export default class Job {
// eslint-disable-next-line class-methods-use-this
canScroll() {
return $(document).height() > $(window).height();
return this.$document.height() > this.$window.height();
}
toggleScroll() {
const currentPosition = $(document).scrollTop();
const scrollHeight = $(document).height();
const currentPosition = this.$document.scrollTop();
const scrollHeight = this.$document.height();
const windowHeight = $(window).height();
const windowHeight = this.$window.height();
if (this.canScroll()) {
if (currentPosition > 0 &&
(scrollHeight - currentPosition !== windowHeight)) {
......@@ -119,7 +114,7 @@ export default class Job {
this.toggleDisableButton(this.$scrollTopBtn, true);
this.toggleDisableButton(this.$scrollBottomBtn, false);
} else if (scrollHeight - currentPosition === windowHeight) {
} else if (this.isScrolledToBottom()) {
// User is at the bottom of the build log.
this.toggleDisableButton(this.$scrollTopBtn, false);
......@@ -131,9 +126,17 @@ export default class Job {
}
}
isScrolledToBottom() {
const currentPosition = this.$document.scrollTop();
const scrollHeight = this.$document.height();
const windowHeight = this.$window.height();
return scrollHeight - currentPosition === windowHeight;
}
// eslint-disable-next-line class-methods-use-this
scrollDown() {
$(document).scrollTop($(document).height());
this.$document.scrollTop(this.$document.height());
}
scrollToBottom() {
......@@ -143,7 +146,7 @@ export default class Job {
}
scrollToTop() {
$(document).scrollTop(0);
this.$document.scrollTop(0);
this.hasBeenScrolled = true;
this.toggleScroll();
}
......@@ -174,7 +177,7 @@ export default class Job {
this.state = log.state;
}
this.windowSize = this.$buildTraceOutput.height();
this.isScrollInBottom = this.isScrolledToBottom();
if (log.append) {
this.$buildTraceOutput.append(log.html);
......@@ -194,14 +197,9 @@ export default class Job {
} else {
this.$truncatedInfo.addClass('hidden');
}
this.isLogComplete = log.complete;
if (!log.complete) {
if (!this.hasBeenScrolled) {
this.toggleScrollAnimation(true);
} else {
this.toggleScrollAnimation(false);
}
this.timeout = setTimeout(() => {
this.getBuildTrace();
}, 4000);
......@@ -218,7 +216,7 @@ export default class Job {
this.$buildRefreshAnimation.remove();
})
.then(() => {
if (!this.hasBeenScrolled) {
if (this.isScrollInBottom) {
this.scrollDown();
}
})
......
......@@ -172,7 +172,6 @@ export const getSelectedFragment = () => {
return documentFragment;
};
// TODO: Update this name, there is a gl.text.insertText function.
export const insertText = (target, text) => {
// Firefox doesn't support `document.execCommand('insertText', false, text)` on textareas
const selectionStart = target.selectionStart;
......
......@@ -2,6 +2,7 @@
import timeago from 'timeago.js';
import dateFormat from 'vendor/date.format';
import { pluralize } from './text_utility';
import {
lang,
......@@ -142,9 +143,9 @@ export function timeIntervalInWords(intervalInSeconds) {
let text = '';
if (minutes >= 1) {
text = `${minutes} ${gl.text.pluralize('minute', minutes)} ${seconds} ${gl.text.pluralize('second', seconds)}`;
text = `${minutes} ${pluralize('minute', minutes)} ${seconds} ${pluralize('second', seconds)}`;
} else {
text = `${seconds} ${gl.text.pluralize('second', seconds)}`;
text = `${seconds} ${pluralize('second', seconds)}`;
}
return text;
}
......
/* eslint-disable import/prefer-default-export, func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, quotes, one-var, one-var-declaration-per-line, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, no-empty, max-len, consistent-return, no-unused-vars, no-return-assign, max-len, vars-on-top */
const textUtils = {};
textUtils.selectedText = function(text, textarea) {
return text.substring(textarea.selectionStart, textarea.selectionEnd);
};
textUtils.lineBefore = function(text, textarea) {
var split;
split = text.substring(0, textarea.selectionStart).trim().split('\n');
return split[split.length - 1];
};
textUtils.lineAfter = function(text, textarea) {
return text.substring(textarea.selectionEnd).trim().split('\n')[0];
};
textUtils.blockTagText = function(text, textArea, blockTag, selected) {
var lineAfter, lineBefore;
lineBefore = this.lineBefore(text, textArea);
lineAfter = this.lineAfter(text, textArea);
if (lineBefore === blockTag && lineAfter === blockTag) {
// To remove the block tag we have to select the line before & after
if (blockTag != null) {
textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1);
textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1);
}
return selected;
} else {
return blockTag + "\n" + selected + "\n" + blockTag;
}
};
textUtils.insertText = function(textArea, text, tag, blockTag, selected, wrap) {
var insertText, inserted, selectedSplit, startChar, removedLastNewLine, removedFirstNewLine, currentLineEmpty, lastNewLine;
removedLastNewLine = false;
removedFirstNewLine = false;
currentLineEmpty = false;
// Remove the first newline
if (selected.indexOf('\n') === 0) {
removedFirstNewLine = true;
selected = selected.replace(/\n+/, '');
}
// Remove the last newline
if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) {
removedLastNewLine = true;
selected = selected.replace(/\n$/, '');
}
selectedSplit = selected.split('\n');
if (!wrap) {
lastNewLine = textArea.value.substr(0, textArea.selectionStart).lastIndexOf('\n');
// Check whether the current line is empty or consists only of spaces(=handle as empty)
if (/^\s*$/.test(textArea.value.substring(lastNewLine, textArea.selectionStart))) {
currentLineEmpty = true;
}
}
startChar = !wrap && !currentLineEmpty && textArea.selectionStart > 0 ? '\n' : '';
if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) {
if (blockTag != null && blockTag !== '') {
insertText = this.blockTagText(text, textArea, blockTag, selected);
} else {
insertText = selectedSplit.map(function(val) {
if (val.indexOf(tag) === 0) {
return "" + (val.replace(tag, ''));
} else {
return "" + tag + val;
}
}).join('\n');
}
} else {
insertText = "" + startChar + tag + selected + (wrap ? tag : ' ');
}
if (removedFirstNewLine) {
insertText = '\n' + insertText;
}
if (removedLastNewLine) {
insertText += '\n';
}
if (document.queryCommandSupported('insertText')) {
inserted = document.execCommand('insertText', false, insertText);
}
if (!inserted) {
try {
document.execCommand("ms-beginUndoUnit");
} catch (error) {}
textArea.value = this.replaceRange(text, textArea.selectionStart, textArea.selectionEnd, insertText);
try {
document.execCommand("ms-endUndoUnit");
} catch (error) {}
}
return this.moveCursor(textArea, tag, wrap, removedLastNewLine);
};
textUtils.moveCursor = function(textArea, tag, wrapped, removedLastNewLine) {
var pos;
if (!textArea.setSelectionRange) {
return;
}
if (textArea.selectionStart === textArea.selectionEnd) {
if (wrapped) {
pos = textArea.selectionStart - tag.length;
} else {
pos = textArea.selectionStart;
}
if (removedLastNewLine) {
pos -= 1;
}
return textArea.setSelectionRange(pos, pos);
}
};
textUtils.updateText = function(textArea, tag, blockTag, wrap) {
var $textArea, selected, text;
$textArea = $(textArea);
textArea = $textArea.get(0);
text = $textArea.val();
selected = this.selectedText(text, textArea);
$textArea.focus();
return this.insertText(textArea, text, tag, blockTag, selected, wrap);
};
textUtils.init = function(form) {
var self;
self = this;
return $('.js-md', form).off('click').on('click', function() {
var $this;
$this = $(this);
return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend'));
});
};
textUtils.removeListeners = function(form) {
return $('.js-md', form).off('click');
};
textUtils.replaceRange = function(s, start, end, substitute) {
return s.substring(0, start) + substitute + s.substring(end);
};
export default textUtils;
/* eslint-disable import/prefer-default-export, func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, quotes, one-var, one-var-declaration-per-line, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, no-empty, max-len, consistent-return, no-unused-vars, no-return-assign, max-len, vars-on-top */
import 'vendor/latinise';
var base;
var w = window;
if (w.gl == null) {
w.gl = {};
}
if ((base = w.gl).text == null) {
base.text = {};
}
gl.text.addDelimiter = function(text) {
return text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") : text;
};
/**
* Adds a , to a string composed by numbers, at every 3 chars.
*
* 2333 -> 2,333
* 232324 -> 232,324
*
* @param {String} text
* @returns {String}
*/
export const addDelimiter = text => (text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : text);
/**
* Returns '99+' for numbers bigger than 99.
......@@ -20,182 +15,50 @@ gl.text.addDelimiter = function(text) {
* @param {Number} count
* @return {Number|String}
*/
export function highCountTrim(count) {
return count > 99 ? '99+' : count;
}
export function capitalizeFirstCharacter(text) {
return `${text[0].toUpperCase()}${text.slice(1)}`;
}
gl.text.randomString = function() {
return Math.random().toString(36).substring(7);
};
gl.text.replaceRange = function(s, start, end, substitute) {
return s.substring(0, start) + substitute + s.substring(end);
};
gl.text.getTextWidth = function(text, font) {
/**
* Uses canvas.measureText to compute and return the width of the given text of given font in pixels.
*
* @param {String} text The text to be rendered.
* @param {String} font The css font descriptor that text is to be rendered with (e.g. "bold 14px verdana").
*
* @see http://stackoverflow.com/questions/118241/calculate-text-width-with-javascript/21015393#21015393
*/
// re-use canvas object for better performance
var canvas = gl.text.getTextWidth.canvas || (gl.text.getTextWidth.canvas = document.createElement('canvas'));
var context = canvas.getContext('2d');
context.font = font;
return context.measureText(text).width;
};
gl.text.selectedText = function(text, textarea) {
return text.substring(textarea.selectionStart, textarea.selectionEnd);
};
gl.text.lineBefore = function(text, textarea) {
var split;
split = text.substring(0, textarea.selectionStart).trim().split('\n');
return split[split.length - 1];
};
gl.text.lineAfter = function(text, textarea) {
return text.substring(textarea.selectionEnd).trim().split('\n')[0];
};
gl.text.blockTagText = function(text, textArea, blockTag, selected) {
var lineAfter, lineBefore;
lineBefore = this.lineBefore(text, textArea);
lineAfter = this.lineAfter(text, textArea);
if (lineBefore === blockTag && lineAfter === blockTag) {
// To remove the block tag we have to select the line before & after
if (blockTag != null) {
textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1);
textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1);
}
return selected;
} else {
return blockTag + "\n" + selected + "\n" + blockTag;
}
};
gl.text.insertText = function(textArea, text, tag, blockTag, selected, wrap) {
var insertText, inserted, selectedSplit, startChar, removedLastNewLine, removedFirstNewLine, currentLineEmpty, lastNewLine;
removedLastNewLine = false;
removedFirstNewLine = false;
currentLineEmpty = false;
export const highCountTrim = count => (count > 99 ? '99+' : count);
// Remove the first newline
if (selected.indexOf('\n') === 0) {
removedFirstNewLine = true;
selected = selected.replace(/\n+/, '');
}
// Remove the last newline
if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) {
removedLastNewLine = true;
selected = selected.replace(/\n$/, '');
}
selectedSplit = selected.split('\n');
if (!wrap) {
lastNewLine = textArea.value.substr(0, textArea.selectionStart).lastIndexOf('\n');
// Check whether the current line is empty or consists only of spaces(=handle as empty)
if (/^\s*$/.test(textArea.value.substring(lastNewLine, textArea.selectionStart))) {
currentLineEmpty = true;
}
}
startChar = !wrap && !currentLineEmpty && textArea.selectionStart > 0 ? '\n' : '';
if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) {
if (blockTag != null && blockTag !== '') {
insertText = this.blockTagText(text, textArea, blockTag, selected);
} else {
insertText = selectedSplit.map(function(val) {
if (val.indexOf(tag) === 0) {
return "" + (val.replace(tag, ''));
} else {
return "" + tag + val;
}
}).join('\n');
}
} else {
insertText = "" + startChar + tag + selected + (wrap ? tag : ' ');
}
/**
* Converst first char to uppercase and replaces undercores with spaces
* @param {String} string
* @requires {String}
*/
export const humanize = string => string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
if (removedFirstNewLine) {
insertText = '\n' + insertText;
}
/**
* Adds an 's' to the end of the string when count is bigger than 0
* @param {String} str
* @param {Number} count
* @returns {String}
*/
export const pluralize = (str, count) => str + (count > 1 || count === 0 ? 's' : '');
if (removedLastNewLine) {
insertText += '\n';
}
/**
* Replaces underscores with dashes
* @param {*} str
* @returns {String}
*/
export const dasherize = str => str.replace(/[_\s]+/g, '-');
if (document.queryCommandSupported('insertText')) {
inserted = document.execCommand('insertText', false, insertText);
}
if (!inserted) {
try {
document.execCommand("ms-beginUndoUnit");
} catch (error) {}
textArea.value = this.replaceRange(text, textArea.selectionStart, textArea.selectionEnd, insertText);
try {
document.execCommand("ms-endUndoUnit");
} catch (error) {}
}
return this.moveCursor(textArea, tag, wrap, removedLastNewLine);
};
gl.text.moveCursor = function(textArea, tag, wrapped, removedLastNewLine) {
var pos;
if (!textArea.setSelectionRange) {
return;
}
if (textArea.selectionStart === textArea.selectionEnd) {
if (wrapped) {
pos = textArea.selectionStart - tag.length;
} else {
pos = textArea.selectionStart;
}
/**
* Removes accents and converts to lower case
* @param {String} str
* @returns {String}
*/
export const slugify = str => str.trim().toLowerCase();
if (removedLastNewLine) {
pos -= 1;
}
/**
* Truncates given text
*
* @param {String} string
* @param {Number} maxLength
* @returns {String}
*/
export const truncate = (string, maxLength) => `${string.substr(0, (maxLength - 3))}...`;
return textArea.setSelectionRange(pos, pos);
}
};
gl.text.updateText = function(textArea, tag, blockTag, wrap) {
var $textArea, selected, text;
$textArea = $(textArea);
textArea = $textArea.get(0);
text = $textArea.val();
selected = this.selectedText(text, textArea);
$textArea.focus();
return this.insertText(textArea, text, tag, blockTag, selected, wrap);
};
gl.text.init = function(form) {
var self;
self = this;
return $('.js-md', form).off('click').on('click', function() {
var $this;
$this = $(this);
return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend'));
});
};
gl.text.removeListeners = function(form) {
return $('.js-md', form).off('click');
};
gl.text.humanize = function(string) {
return string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
};
gl.text.pluralize = function(str, count) {
return str + (count > 1 || count === 0 ? 's' : '');
};
gl.text.truncate = function(string, maxLength) {
return string.substr(0, (maxLength - 3)) + '...';
};
gl.text.dasherize = function(str) {
return str.replace(/[_\s]+/g, '-');
};
gl.text.slugify = function(str) {
return str.trim().toLowerCase().latinise();
};
/**
* Capitalizes first character.
*
* @param {String} text
* @returns {String}
*/
export const capitalizeFirstCharacter = text => `${text[0].toUpperCase()}${text.slice(1)}`;
......@@ -30,7 +30,6 @@ import './commit/image_file';
import { handleLocationHash } from './lib/utils/common_utils';
import './lib/utils/datetime_utility';
import './lib/utils/pretty_time';
import './lib/utils/text_utility';
import './lib/utils/url_utility';
// behaviors
......
......@@ -5,6 +5,7 @@ import 'vendor/jquery.waitforimages';
import TaskList from './task_list';
import './merge_request_tabs';
import IssuablesHelper from './helpers/issuables_helper';
import { addDelimiter } from './lib/utils/text_utility';
(function() {
this.MergeRequest = (function() {
......@@ -124,7 +125,7 @@ import IssuablesHelper from './helpers/issuables_helper';
const $el = $('.nav-links .js-merge-counter');
const count = Math.max((parseInt($el.text().replace(/[^\d]/, ''), 10) - by), 0);
$el.text(gl.text.addDelimiter(count));
$el.text(addDelimiter(count));
};
MergeRequest.prototype.hideCloseButton = function() {
......
......@@ -357,7 +357,8 @@
@click="handleSave(true)"
v-if="canUpdateIssue"
:class="actionButtonClassNames"
class="btn btn-comment btn-comment-and-close">
:disabled="isSubmitting"
class="btn btn-comment btn-comment-and-close js-action-button">
{{issueActionButtonTitle}}
</button>
<button
......
<script>
import tooltip from '../../../vue_shared/directives/tooltip';
import icon from '../../../vue_shared/components/icon.vue';
import { dasherize } from '../../../lib/utils/text_utility';
/**
* Renders either a cancel, retry or play icon pointing to the given path.
* TODO: Remove UJS from here and use an async request instead.
......@@ -39,7 +39,7 @@
computed: {
cssClass() {
const actionIconDash = gl.text.dasherize(this.actionIcon);
const actionIconDash = dasherize(this.actionIcon);
return `${actionIconDash} js-icon-${actionIconDash}`;
},
},
......
......@@ -64,6 +64,9 @@ export default {
);
this.monacoInstance.setModel(newModel);
this.monacoInstance.updateOptions({
readOnly: !!this.activeFile.file_lock,
});
},
addMonacoEvents() {
this.monacoInstance.onKeyUp(() => {
......@@ -87,7 +90,7 @@ export default {
'activeFileExtension',
]),
shouldHideEditor() {
return this.activeFile.binary && !this.activeFile.raw;
return this.activeFile && this.activeFile.binary && !this.activeFile.raw;
},
},
};
......
......@@ -2,6 +2,7 @@
import { mapActions, mapGetters } from 'vuex';
import timeAgoMixin from '../../vue_shared/mixins/timeago';
import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue';
import fileStatusIcon from './repo_file_status_icon.vue';
export default {
mixins: [
......@@ -9,6 +10,7 @@
],
components: {
skeletonLoadingContainer,
fileStatusIcon,
},
props: {
file: {
......@@ -70,6 +72,9 @@
class="repo-file-name"
>
{{ file.name }}
<fileStatusIcon
:file="file">
</fileStatusIcon>
</a>
<template v-if="isSubmodule && file.id">
@
......
<script>
import icon from '../../vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
import '../../lib/utils/datetime_utility';
export default {
components: {
icon,
},
directives: {
tooltip,
},
props: {
file: {
type: Object,
required: true,
},
},
computed: {
lockTooltip() {
return `Locked by ${this.file.file_lock.user.name}`;
},
},
};
</script>
<template>
<span
v-if="file.file_lock"
v-tooltip
:title="lockTooltip"
data-container="body"
>
<icon
name="lock"
css-classes="file-status-icon"
>
</icon>
</span>
</template>
<script>
import { mapActions } from 'vuex';
import fileStatusIcon from './repo_file_status_icon.vue';
export default {
props: {
......@@ -9,6 +10,10 @@ export default {
},
},
components: {
fileStatusIcon,
},
computed: {
closeLabel() {
if (this.tab.changed || this.tab.tempFile) {
......@@ -57,6 +62,9 @@ export default {
:title="tab.url"
@click.prevent.stop="setFileActive(tab)">
{{tab.name}}
<fileStatusIcon
:file="tab">
</fileStatusIcon>
</a>
</li>
</template>
......@@ -3,7 +3,7 @@ import flash from '../../flash';
import service from '../services';
import * as types from './mutation_types';
export const redirectToUrl = url => gl.utils.visitUrl(url);
export const redirectToUrl = (_, url) => gl.utils.visitUrl(url);
export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
......@@ -84,7 +84,7 @@ export const commitChanges = ({ commit, state, dispatch, getters }, { payload, n
flash(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice');
if (newMr) {
redirectToUrl(`${state.endpoints.newMergeRequestUrl}${branch}`);
dispatch('redirectToUrl', `${state.endpoints.newMergeRequestUrl}${branch}`);
} else {
commit(types.SET_COMMIT_REF, data.id);
......
......@@ -3,16 +3,16 @@ import * as types from '../mutation_types';
import { pushState } from '../utils';
// eslint-disable-next-line import/prefer-default-export
export const createNewBranch = ({ rootState, commit }, branch) => service.createBranch(
rootState.project.id,
export const createNewBranch = ({ state, commit }, branch) => service.createBranch(
state.project.id,
{
branch,
ref: rootState.currentBranch,
ref: state.currentBranch,
},
).then(res => res.json())
.then((data) => {
const branchName = data.name;
const url = location.href.replace(rootState.currentBranch, branchName);
const url = location.href.replace(state.currentBranch, branchName);
pushState(url);
......
......@@ -51,6 +51,9 @@ export const decorateData = (entity) => {
parentTreeUrl = '',
level = 0,
base64 = false,
file_lock,
} = entity;
return {
......@@ -72,6 +75,9 @@ export const decorateData = (entity) => {
renderError,
content,
base64,
file_lock,
};
};
......
import tooltip from '../../vue_shared/directives/tooltip';
import '../../lib/utils/text_utility';
import { pluralize } from '../../lib/utils/text_utility';
export default {
name: 'MRWidgetHeader',
......@@ -14,7 +14,7 @@ export default {
return this.mr.divergedCommitsCount > 0;
},
commitsText() {
return gl.text.pluralize('commit', this.mr.divergedCommitsCount);
return pluralize('commit', this.mr.divergedCommitsCount);
},
branchNameClipboardData() {
// This supports code in app/assets/javascripts/copy_to_clipboard.js that
......
......@@ -61,7 +61,7 @@ export default {
return this.mr.hasCI;
},
shouldRenderRelatedLinks() {
return this.mr.relatedLinks;
return !!this.mr.relatedLinks;
},
shouldRenderDeployments() {
return this.mr.deployments.length;
......
......@@ -35,6 +35,11 @@ export default {
type: String,
required: false,
},
containerClass: {
type: String,
required: false,
default: 'btn btn-align-content',
},
},
components: {
loadingIcon,
......@@ -49,9 +54,9 @@ export default {
<template>
<button
class="btn btn-align-content"
@click="onClick"
type="button"
:class="containerClass"
:disabled="loading || disabled"
>
<transition name="fade">
......
import bp from './breakpoints';
import { slugify } from './lib/utils/text_utility';
export default class Wikis {
constructor() {
......@@ -23,7 +24,7 @@ export default class Wikis {
if (!this.newWikiForm) return;
const slugInput = this.newWikiForm.querySelector('#new_wiki_path');
const slug = gl.text.slugify(slugInput.value);
const slug = slugify(slugInput.value);
if (slug.length > 0) {
const wikisPath = slugInput.getAttribute('data-wikis-path');
......
......@@ -353,3 +353,7 @@
display: -webkit-flex;
display: flex;
}
.flex-right {
margin-left: auto;
}
......@@ -1029,3 +1029,32 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
}
}
}
.new-epic-dropdown {
.dropdown-menu {
padding-left: $gl-padding-top;
padding-right: $gl-padding-top;
}
.form-control {
width: 100%;
}
.btn-save {
display: flex;
margin-top: $gl-btn-padding;
}
}
.empty-state .new-epic-dropdown {
display: inline-flex;
.btn-save {
margin-left: 0;
margin-bottom: 0;
}
.btn-new {
margin: 0;
}
}
......@@ -101,13 +101,13 @@
@for $i from 0 through 5 {
.legend-box-#{$i} {
background-color: mix($blame-cyan, $blame-blue, $i / 5.0 * 100%);
background-color: mix($blame-cyan, $blame-blue, $i / 5 * 100%);
}
}
@for $i from 1 through 4 {
.legend-box-#{$i + 5} {
background-color: mix($blame-gray, $blame-cyan, $i / 4.0 * 100%);
background-color: mix($blame-gray, $blame-cyan, $i / 4 * 100%);
}
}
}
......@@ -200,13 +200,13 @@
@for $i from 0 through 5 {
td.blame-commit-age-#{$i} {
border-left-color: mix($blame-cyan, $blame-blue, $i / 5.0 * 100%);
border-left-color: mix($blame-cyan, $blame-blue, $i / 5 * 100%);
}
}
@for $i from 1 through 4 {
td.blame-commit-age-#{$i + 5} {
border-left-color: mix($blame-gray, $blame-cyan, $i / 4.0 * 100%);
border-left-color: mix($blame-gray, $blame-cyan, $i / 4 * 100%);
}
}
}
......
......@@ -401,10 +401,13 @@
.breadcrumbs-list {
display: -webkit-flex;
display: flex;
flex-wrap: wrap;
margin-bottom: 0;
line-height: 16px;
@media (max-width: $screen-xs-max) {
flex-wrap: wrap;
}
> li {
display: flex;
align-items: center;
......@@ -412,24 +415,35 @@
padding: 2px 0;
&:not(:last-child) {
margin-right: 20px;
padding-right: 20px;
&:not(.dropdown) {
overflow: hidden;
}
}
> a {
font-size: 12px;
color: currentColor;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 0 1 auto;
}
}
}
.breadcrumb-item-text {
@include str-truncated(128px);
text-decoration: inherit;
@media (max-width: $screen-xs-max) {
@include str-truncated(128px);
}
}
.breadcrumbs-list-angle {
position: absolute;
right: -12px;
right: 7px;
top: 50%;
color: $gl-text-color-tertiary;
transform: translateY(-50%);
......
......@@ -164,7 +164,7 @@ $gl-text-color: #2e2e2e;
$gl-text-color-secondary: #707070;
$gl-text-color-tertiary: #949494;
$gl-text-color-quaternary: #d6d6d6;
$gl-text-color-inverted: rgba(255, 255, 255, 1.0);
$gl-text-color-inverted: rgba(255, 255, 255, 1);
$gl-text-color-secondary-inverted: rgba(255, 255, 255, .85);
$gl-text-green: $green-600;
$gl-text-green-hover: $green-700;
......@@ -493,8 +493,8 @@ $callout-success-color: $green-700;
/*
* Commit Page
*/
$commit-max-width-marker-color: rgba(0, 0, 0, 0.0);
$commit-message-text-area-bg: rgba(0, 0, 0, 0.0);
$commit-max-width-marker-color: rgba(0, 0, 0, 0);
$commit-message-text-area-bg: rgba(0, 0, 0, 0);
/*
* Common
......
......@@ -333,7 +333,7 @@
.prometheus-graph-overlay {
fill: none;
opacity: 0.0;
opacity: 0;
pointer-events: all;
}
......
......@@ -697,6 +697,7 @@
.issue-main-info {
flex: 1 auto;
margin-right: 10px;
min-width: 0;
}
.issuable-meta {
......
@import "./issues/issue_count_badge";
@import "./issues/related_issues";
.issues-list {
.issue {
......
......@@ -18,7 +18,7 @@ $token_spacing_bottom: 0.5em;
}
.related-issues-token-body {
padding-bottom: calc(#{$gl-padding} - #{$token_spacing_bottom});
padding: 0;
transition-property: max-height, padding, opacity;
transition-duration: $general-hover-transition-duration;
transition-timing-function: $general-hover-transition-curve;
......@@ -30,6 +30,18 @@ $token_spacing_bottom: 0.5em;
padding-bottom: 0;
opacity: 0;
}
li .issue-info-container {
padding-left: $gl-padding;
}
a.issue-main-info:hover {
text-decoration: none;
.issue-token-title-text {
text-decoration: underline;
}
}
}
.related-issues-loading-icon {
......@@ -50,3 +62,7 @@ $token_spacing_bottom: 0.5em;
margin-bottom: $token_spacing_bottom;
margin-right: 5px;
}
.issue-token-end {
order: 1;
}
......@@ -715,13 +715,16 @@
.approvals-footer {
display: flex;
.approvers-prefix,
.approvers-list {
.approvers-prefix {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.approvers-list {
display: flex;
align-items: center;
.link-to-member-avatar:not(:first-child) {
img {
margin-left: 0;
......
......@@ -7,7 +7,7 @@
.diff-file .diff-content {
tr.line_holder:hover > td .line_note_link {
opacity: 1.0;
opacity: 1;
filter: alpha(opacity = 100);
}
}
......
......@@ -289,6 +289,13 @@
color: $almost-black;
}
}
.file-status-icon {
width: 10px;
height: 10px;
margin-left: 3px;
}
}
.render-error {
......
......@@ -274,3 +274,22 @@
}
}
}
.modal-doorkeepr-auth,
.doorkeeper-app-form {
.scope-description {
color: $theme-gray-700;
}
}
.modal-doorkeepr-auth {
.modal-body {
padding: $gl-padding;
}
}
.doorkeeper-app-form {
.scope-description {
margin: 0 0 5px 17px;
}
}
......@@ -76,7 +76,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
def redirect_out_of_range(todos)
total_pages =
if todo_params.except(:sort, :page).empty?
(current_user.todos_pending_count / todos.limit_value).ceil
(current_user.todos_pending_count.to_f / todos.limit_value).ceil
else
todos.total_pages
end
......
......@@ -52,15 +52,13 @@ class Projects::RefsController < Projects::ApplicationController
contents.push(*tree.blobs)
contents.push(*tree.submodules)
show_path_locks = @project.feature_available?(:file_locks) && @project.path_locks.any?
# n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37433
@logs = Gitlab::GitalyClient.allow_n_plus_1_calls do
contents[@offset, @limit].to_a.map do |content|
file = @path ? File.join(@path, content.name) : content.name
last_commit = @repo.last_commit_for_path(@commit.id, file)
commit_path = project_commit_path(@project, last_commit) if last_commit
path_lock = show_path_locks && @project.find_path_lock(file)
path_lock = @project.find_path_lock(file)
{
file_name: content.name,
......
module EE
module LockHelper
def lock_file_link(project = @project, path = @path, html_options: {})
return unless project.feature_available?(:file_locks) && current_user
return unless current_user
return if path.blank?
path_lock = project.find_path_lock(path, downstream: true)
......@@ -67,7 +67,6 @@ module EE
end
def render_lock_icon(path)
return unless @project.feature_available?(:file_locks)
return unless @project.root_ref?(@ref)
if file_lock = @project.find_path_lock(path, exact_match: true)
......
......@@ -23,10 +23,17 @@ module IconsHelper
render "shared/icons/#{icon_name}.svg", size: size
end
def sprite_icon_path
# SVG Sprites currently don't work across domains, so in the case of a CDN
# we have to set the current path deliberately to prevent addition of asset_host
sprite_base_url = Gitlab.config.gitlab.url if ActionController::Base.asset_host
ActionController::Base.helpers.image_path('icons.svg', host: sprite_base_url)
end
def sprite_icon(icon_name, size: nil, css_class: nil)
css_classes = size ? "s#{size}" : ""
css_classes << " #{css_class}" unless css_class.blank?
content_tag(:svg, content_tag(:use, "", { "xlink:href" => "#{image_path('icons.svg')}##{icon_name}" } ), class: css_classes.empty? ? nil : css_classes)
content_tag(:svg, content_tag(:use, "", { "xlink:href" => "#{sprite_icon_path}##{icon_name}" } ), class: css_classes.empty? ? nil : css_classes)
end
def audit_icon(names, options = {})
......
......@@ -215,6 +215,7 @@ module IssuablesHelper
endpoint: issuable_path(issuable),
canUpdate: can?(current_user, :"update_#{issuable.to_ability_name}", issuable),
canDestroy: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable),
canAdmin: can?(current_user, :"admin_#{issuable.to_ability_name}", issuable),
issuableRef: issuable.to_reference,
markdownPreviewPath: preview_markdown_path(parent),
markdownDocsPath: help_page_path('user/markdown'),
......@@ -228,6 +229,7 @@ module IssuablesHelper
if parent.is_a?(Group)
data[:groupPath] = parent.path
data[:issueLinksEndpoint] = group_epic_issues_path(parent, issuable)
else
data.merge!(projectPath: ref_project.path, projectNamespace: ref_project.namespace.full_path)
end
......
......@@ -4,15 +4,26 @@ module Avatarable
def avatar_path(only_path: true)
return unless self[:avatar].present?
# If only_path is true then use the relative path of avatar.
# Otherwise use full path (including host).
asset_host = ActionController::Base.asset_host
gitlab_host = only_path ? gitlab_config.relative_url_root : gitlab_config.url
use_asset_host = asset_host.present?
# If asset_host is set then it is expected that assets are handled by a standalone host.
# That means we do not want to get GitLab's relative_url_root option anymore.
host = (asset_host.present? && (!respond_to?(:public?) || public?)) ? asset_host : gitlab_host
# Avatars for private and internal groups and projects require authentication to be viewed,
# which means they can only be served by Rails, on the regular GitLab host.
# If an asset host is configured, we need to return the fully qualified URL
# instead of only the avatar path, so that Rails doesn't prefix it with the asset host.
if use_asset_host && respond_to?(:public?) && !public?
use_asset_host = false
only_path = false
end
[host, avatar.url].join
url_base = ""
if use_asset_host
url_base << asset_host unless only_path
else
url_base << gitlab_config.base_url unless only_path
url_base << gitlab_config.relative_url_root
end
url_base + avatar.url
end
end
......@@ -38,6 +38,9 @@ class Issue < ActiveRecord::Base
has_many :issue_assignees
has_many :assignees, class_name: "User", through: :issue_assignees
has_one :epic_issue
has_one :epic, through: :epic_issue
validates :project, presence: true
scope :in_projects, ->(project_ids) { where(project_id: project_ids) }
......
......@@ -891,7 +891,19 @@ class MergeRequest < ActiveRecord::Base
#
def all_commit_shas
if persisted?
column_shas = MergeRequestDiffCommit.where(merge_request_diff: merge_request_diffs).limit(10_000).pluck('sha')
# MySQL doesn't support LIMIT in a subquery.
diffs_relation =
if Gitlab::Database.postgresql?
merge_request_diffs.order(id: :desc).limit(100)
else
merge_request_diffs
end
column_shas = MergeRequestDiffCommit
.where(merge_request_diff: diffs_relation)
.limit(10_000)
.pluck('sha')
serialised_shas = merge_request_diffs.where.not(st_commits: nil).flat_map(&:commit_shas)
(column_shas + serialised_shas).uniq
......
......@@ -635,6 +635,8 @@ class Project < ActiveRecord::Base
else
super
end
rescue
super
end
def valid_import_url?
......
......@@ -1124,6 +1124,10 @@ class Repository
blob_data_at(sha, path)
end
def fetch_ref(source_repository, source_ref:, target_ref:)
raw_repository.fetch_ref(source_repository.raw_repository, source_ref: source_ref, target_ref: target_ref)
end
private
# TODO Generice finder, later split this on finders by Ref or Oid
......
......@@ -10,4 +10,10 @@ class BlobEntity < Grape::Entity
expose :url do |blob|
project_blob_path(request.project, File.join(request.ref, blob.path))
end
expose :file_lock, using: FileLockEntity do |blob|
if request.project.root_ref?(request.ref)
request.project.find_path_lock(blob.path, exact_match: true)
end
end
end
class FileLockEntity < Grape::Entity
expose :user, using: API::Entities::UserSafe
end
......@@ -3,7 +3,6 @@ class IssueEntity < IssuableEntity
expose :state
expose :deleted_at
expose :branch_name
expose :confidential
expose :discussion_locked
expose :assignees, using: API::Entities::UserBasic
......
......@@ -6,6 +6,7 @@
# when the user is destroyed.
module Users
class MigrateToGhostUserService
prepend EE::Users::MigrateToGhostUserService
extend ActiveSupport::Concern
attr_reader :ghost_user, :user
......
= form_for application, url: doorkeeper_submit_path(application), html: {role: 'form'} do |f|
= form_for application, url: doorkeeper_submit_path(application), html: { role: 'form', class: 'doorkeeper-app-form' } do |f|
= form_errors(application)
.form-group
......
- auth_app_owner = @pre_auth.client.application.owner
%main{ :role => "main" }
.modal-no-backdrop
.modal-no-backdrop.modal-doorkeepr-auth
.modal-content
.modal-header
%h3.page-title
......@@ -16,14 +18,21 @@
%strong= @pre_auth.client.name
will allow them to interact with GitLab as an admin as well. Proceed with caution.
%p
You are about to authorize
An application called
= link_to @pre_auth.client.name, @pre_auth.redirect_uri, target: '_blank', rel: 'noopener noreferrer'
to use your account.
- if @pre_auth.scopes
is requesting access to your GitLab account. This application was created by
= succeed "." do
= link_to auth_app_owner.name, user_path(auth_app_owner)
Please note that this application is not provided by GitLab and you should verify its authenticity before
allowing access.
- if @pre_auth.scopes
%p
This application will be able to:
%ul
- @pre_auth.scopes.each do |scope|
%li= t scope, scope: [:doorkeeper, :scopes]
%li
%strong= t scope, scope: [:doorkeeper, :scopes]
.scope-description= t scope, scope: [:doorkeeper, :scope_desc]
.form-actions.text-right
= form_tag oauth_authorization_path, method: :delete, class: 'inline' do
= hidden_field_tag :client_id, @pre_auth.client.uid
......
- issues = IssuesFinder.new(current_user, group_id: @group.id, state: 'opened').execute
- merge_requests = MergeRequestsFinder.new(current_user, group_id: @group.id, state: 'opened', non_archived: true).execute
- epics = EpicsFinder.new(current_user, group_id: @group.id).execute
- epics_items = ['epics#show', 'epics#index']
- issues_sub_menu_items = ['groups#issues', 'labels#index', 'milestones#index']
- if @group.feature_available?(:group_issue_boards)
- issues_sub_menu_items.push('boards#index', 'boards#show')
......@@ -45,20 +43,8 @@
%span
Contribution Analytics
-# TODO: Add the flag check to only show epics if available
= nav_link(path: epics_items) do
= link_to group_epics_path(@group) do
.nav-icon-container
= sprite_icon('epic')
%span.nav-item-name
Epics
%span.badge.count= number_with_delimiter(epics.count)
%ul.sidebar-sub-level-items.is-fly-out-only
= nav_link(path: epics_items, html_options: { class: "fly-out-top-item" } ) do
= link_to group_epics_path(@group) do
%strong.fly-out-top-item-name
#{ _('Epics') }
%span.badge.count.epic_counter.fly-out-badge= number_with_delimiter(epics.count)
= render "layouts/nav/ee/epic_link", group: @group
= nav_link(path: issues_sub_menu_items) do
= link_to issues_group_path(@group) do
......
......@@ -7,3 +7,4 @@
= check_box_tag "#{prefix}[scopes][]", scope, token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}"
= label_tag ("#{prefix}_scopes_#{scope}"), scope
%span= t(scope, scope: [:doorkeeper, :scopes])
.scope-description= t scope, scope: [:doorkeeper, :scope_desc]
......@@ -2,23 +2,18 @@
# vim: ft=ruby
require 'rubygems'
require 'bundler/setup'
# loads rails environment / initializers
require "#{File.dirname(__FILE__)}/../config/environment"
require 'optparse'
class GeoLogCursorOptionParser
def self.parse(argv)
options = { full_scan: false }
version = Gitlab::Geo::LogCursor::Daemon::VERSION
options = {}
op = OptionParser.new
op.banner = 'GitLab Geo: Log Cursor'
op.separator ''
op.separator 'Usage: ./geo_log_cursor [options]'
op.separator ''
op.on('-f', '--full-scan', 'Performs full-scan to lookup for un-replicated data') { options[:full_scan] = true }
op.on('-d', '--debug', 'Enable detailed logging with extra debug information') { options[:debug] = true }
op.separator 'Common options:'
op.on('-h', '--help') do
......@@ -26,7 +21,10 @@ class GeoLogCursorOptionParser
exit
end
op.on('-v', '--version') do
puts version
# Load only necessary libraries for faster startup
require "#{File.dirname(__FILE__)}/../lib/gitlab/geo/log_cursor/daemon"
puts Gitlab::Geo::LogCursor::Daemon::VERSION
exit
end
op.separator ''
......@@ -40,5 +38,8 @@ end
if $0 == __FILE__
options = GeoLogCursorOptionParser.parse(ARGV)
# We load rails environment / initializers only here to get faster command line startup when `--help` and `--version`
require "#{File.dirname(__FILE__)}/../config/environment"
Gitlab::Geo::LogCursor::Daemon.new(options).run!
end
---
title: Fix Merge Request Widget Approvals responsiveness on mobile
merge_request:
author:
type: fixed
---
title: Introduce EEU lincese with epics as the first feature
merge_request:
author:
type: added
---
title: Add ability to create new epics
merge_request:
author:
type: added
---
title: Remove the full-scan option from the Geo log cursor
merge_request: 3412
author:
type: removed
---
title: Fix error when entering an invalid url to push to or pull from a remote repository
merge_request: 3389
author:
type: fixed
---
title: Adds typescript support
title: Add delete epic button
merge_request:
author:
type: added
---
title: Hide Approvals section when Merge Request Widget is showing the empty state
merge_request: 3376
author:
type: fixed
---
title: Make GeoLogCursor Highly Available
merge_request: 3305
author:
type: added
---
title: Enables scroll to bottom once user has scrolled back to bottom in job log
merge_request:
author:
type: fixed
---
title: Fix acceptance of username for Mattermost service update
merge_request: 15275
author:
type: fixed
---
title: Clean up schema of the "issues" table
merge_request:
author:
type: other
---
title: Always return full avatar URL for private/internal groups/projects when asset
host is set
merge_request:
author:
type: fixed
---
title: Ensure merge requests with lots of version don't time out when searching for
pipelines
merge_request:
author:
type: performance
---
title: Fix access to the final page of todos
merge_request:
author:
type: fixed
---
title: Export text utils functions as es6 module and add tests
merge_request:
author:
type: other
......@@ -62,7 +62,15 @@ en:
read_user: Read the authenticated user's personal information
openid: Authenticate using OpenID Connect
sudo: Perform API actions as any user in the system (if the authenticated user is an admin)
scope_desc:
api:
Full access to GitLab as the user, including read/write on all their groups and projects
read_user:
Read-only access to the user's profile information, like username, public email and full name
openid:
The ability to authenticate using GitLab, and read-only access to the user's profile information
sudo:
Access to the Sudo feature, to perform API actions as any user in the system (only available for admins)
flash:
applications:
create:
......
......@@ -145,10 +145,10 @@
- container_memory_usage_bytes
weight: 1
queries:
- query_range: '(sum(container_memory_usage_bytes{container_name!="POD",environment="%{ci_environment_slug}"}) / count(container_memory_usage_bytes{container_name!="POD",environment="%{ci_environment_slug}"})) /1024/1024'
- query_range: '(sum(avg(container_memory_usage_bytes{container_name!="POD",environment="%{ci_environment_slug}"}) without (job))) / count(avg(container_memory_usage_bytes{container_name!="POD",environment="%{ci_environment_slug}"}) without (job)) /1024/1024'
label: Average
unit: MB
- query_range: '(sum(container_memory_usage_bytes{container_name!="POD",environment="%{ci_environment_slug}-canary"}) / count(container_memory_usage_bytes{container_name!="POD",environment="%{ci_environment_slug}-canary"})) /1024/1024'
- query_range: '(sum(avg(container_memory_usage_bytes{container_name!="POD",environment="%{ci_environment_slug}-canary"}) without (job))) / count(avg(container_memory_usage_bytes{container_name!="POD",environment="%{ci_environment_slug}-canary"}) without (job)) /1024/1024'
label: Average
unit: MB
track: canary
......@@ -158,10 +158,10 @@
- container_cpu_usage_seconds_total
weight: 1
queries:
- query_range: 'sum(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="%{ci_environment_slug}"}[2m])) * 100'
label: CPU
- query_range: 'sum(avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="%{ci_environment_slug}"}[2m])) without (job)) * 100'
label: Average
unit: "%"
- query_range: 'sum(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="%{ci_environment_slug}-canary"}[2m])) * 100'
label: CPU
- query_range: 'sum(avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="%{ci_environment_slug}-canary"}[2m])) without (job)) * 100'
label: Average
unit: "%"
track: canary
\ No newline at end of file
track: canary
......@@ -78,6 +78,8 @@ constraints(GroupUrlConstrainer.new) do
member do
get :realtime_changes
end
resources :epic_issues, only: [:index, :create, :destroy], as: 'issues', path: 'issues'
end
legacy_ee_group_boards_redirect = redirect do |params, request|
......
......@@ -41,6 +41,7 @@ var config = {
environments: './environments/environments_bundle.js',
environments_folder: './environments/folder/environments_folder_bundle.js',
epic_show: 'ee/epics/epic_show/epic_show_bundle.js',
new_epic: 'ee/epics/new_epic/new_epic_bundle.js',
filtered_search: './filtered_search/filtered_search_bundle.js',
graphs: './graphs/graphs_bundle.js',
graphs_charts: './graphs/graphs_charts.js',
......@@ -117,10 +118,6 @@ var config = {
test: /\.vue$/,
loader: 'vue-loader',
},
{
test: /\.ts$/,
loader: 'ts-loader',
},
{
test: /\.svg$/,
loader: 'raw-loader',
......@@ -269,7 +266,7 @@ var config = {
],
resolve: {
extensions: ['.js', '.ts'],
extensions: ['.js'],
alias: {
'ee': path.join(ROOT_PATH, 'ee/app/assets/javascripts'),
'~': path.join(ROOT_PATH, 'app/assets/javascripts'),
......
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class IssuesConfidentialNotNull < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
class Issue < ActiveRecord::Base
self.table_name = 'issues'
end
def up
Issue.where('confidential IS NULL').update_all(confidential: false)
change_column_null :issues, :confidential, false
end
def down
# There's no way / point to revert this.
end
end
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class IssuesMilestoneIdForeignKey < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
disable_ddl_transaction!
class Issue < ActiveRecord::Base
include EachBatch
self.table_name = 'issues'
def self.with_orphaned_milestones
where('NOT EXISTS (SELECT true FROM milestones WHERE milestones.id = issues.milestone_id)')
end
end
def up
Issue.with_orphaned_milestones.each_batch(of: 100) do |batch|
batch.update_all(milestone_id: nil)
end
add_concurrent_foreign_key(
:issues,
:milestones,
column: :milestone_id,
on_delete: :nullify
)
end
def down
remove_foreign_key_without_error(:issues, column: :milestone_id)
end
end
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class IssuesUpdatedByIdForeignKey < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
disable_ddl_transaction!
class Issue < ActiveRecord::Base
include EachBatch
self.table_name = 'issues'
def self.with_orphaned_updaters
where('NOT EXISTS (SELECT true FROM users WHERE users.id = issues.updated_by_id)')
.where('updated_by_id IS NOT NULL')
end
end
def up
Issue.with_orphaned_updaters.each_batch(of: 100) do |batch|
batch.update_all(updated_by_id: nil)
end
# This index is only used for foreign keys, and those in turn will always
# specify a value. As such we can add a WHERE condition to make the index
# smaller.
add_concurrent_index(:issues, :updated_by_id, where: 'updated_by_id IS NOT NULL')
add_concurrent_foreign_key(
:issues,
:users,
column: :updated_by_id,
on_delete: :nullify
)
end
def down
remove_foreign_key_without_error(:issues, column: :updated_by_id)
remove_concurrent_index(:issues, :updated_by_id)
end
end
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class IssuesMovedToIdForeignKey < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
disable_ddl_transaction!
class Issue < ActiveRecord::Base
include EachBatch
self.table_name = 'issues'
def self.with_orphaned_moved_to_issues
where('NOT EXISTS (SELECT true FROM issues WHERE issues.id = issues.moved_to_id)')
.where('moved_to_id IS NOT NULL')
end
end
def up
Issue.with_orphaned_moved_to_issues.each_batch(of: 100) do |batch|
batch.update_all(moved_to_id: nil)
end
add_concurrent_foreign_key(
:issues,
:issues,
column: :moved_to_id,
on_delete: :nullify
)
# We're using a partial index here so we only index the data we actually
# care about.
add_concurrent_index(:issues, :moved_to_id, where: 'moved_to_id IS NOT NULL')
end
def down
remove_foreign_key_without_error(:issues, column: :moved_to_id)
remove_concurrent_index(:issues, :moved_to_id)
end
end
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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