Commit 4581a78d authored by Phil Hughes's avatar Phil Hughes

Merge branch 'master' into ide-staged-changes

parents 690c5786 671e93dc
......@@ -10,12 +10,6 @@ engines:
- javascript
exclude_paths:
- "lib/api/v3/*"
eslint:
enabled: true
channel: "eslint-4"
rubocop:
enabled: true
channel: "gitlab-rubocop-0-52-1"
ratings:
paths:
- Gemfile.lock
......
......@@ -727,9 +727,6 @@ codequality:
cache: {}
dependencies: []
script:
# Get the custom rubocop codeclimate image (https://gitlab.com/gitlab-org/codeclimate-rubocop/wikis/home)
- docker pull dev.gitlab.org:5005/gitlab/gitlab-build-images:gitlab-codeclimate-rubocop-0-52-1
- docker tag dev.gitlab.org:5005/gitlab/gitlab-build-images:gitlab-codeclimate-rubocop-0-52-1 codeclimate/codeclimate-rubocop:gitlab-codeclimate-rubocop-0-52-1
# Extract "MAJOR.MINOR" from CI_SERVER_VERSION and generate "MAJOR-MINOR-stable" for Security Products
- export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/')
- docker run --env SOURCE_CODE="$PWD" --volume "$PWD":/code --volume /var/run/docker.sock:/var/run/docker.sock "registry.gitlab.com/gitlab-org/security-products/codequality:$SP_VERSION" /code
......@@ -765,7 +762,13 @@ qa:selectors:
- bundle exec bin/qa Test::Sanity::Selectors
coverage:
<<: *dedicated-no-docs-no-db-pull-cache-job
# Don't include dedicated-no-docs-no-db-pull-cache-job here since we need to
# download artifacts from all the rspec jobs instead of from setup-test-env only
<<: *dedicated-runner
<<: *except-docs-and-qa
<<: *pull-cache
variables:
SETUP_DB: "false"
stage: post-test
script:
- bundle exec scripts/merge-simplecov
......
......@@ -59,6 +59,8 @@ linters:
# Reports when you define the same property twice in a single rule set.
DuplicateProperty:
enabled: true
ignore_consecutive:
- cursor
# Separate rule, function, and mixin declarations with empty lines.
EmptyLineBetweenBlocks:
......
......@@ -2,6 +2,14 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
## 10.6.3 (2018-04-03)
### Security (2 changes)
- Fix XSS on diff view stored on filenames.
- Adds confidential notes channel for Slack/Mattermost.
## 10.6.2 (2018-03-29)
### Fixed (2 changes, 1 of them is from the community)
......@@ -217,6 +225,14 @@ entry.
- Use host URL to build JIRA remote link icon.
## 10.5.7 (2018-04-03)
### Security (2 changes)
- Fix XSS on diff view stored on filenames.
- Adds confidential notes channel for Slack/Mattermost.
## 10.5.6 (2018-03-16)
### Security (2 changes)
......@@ -484,6 +500,14 @@ entry.
- Adds empty state illustration for pending job.
## 10.4.7 (2018-04-03)
### Security (2 changes)
- Fix XSS on diff view stored on filenames.
- Adds confidential notes channel for Slack/Mattermost.
## 10.4.6 (2018-03-16)
### Security (2 changes)
......
......@@ -421,7 +421,7 @@ group :ed25519 do
end
# Gitaly GRPC client
gem 'gitaly-proto', '~> 0.91.0', require: 'gitaly'
gem 'gitaly-proto', '~> 0.94.0', require: 'gitaly'
gem 'grpc', '~> 1.10.0'
# Locked until https://github.com/google/protobuf/issues/4210 is closed
......
......@@ -290,7 +290,7 @@ GEM
po_to_json (>= 1.0.0)
rails (>= 3.2.0)
gherkin-ruby (0.3.2)
gitaly-proto (0.91.0)
gitaly-proto (0.94.0)
google-protobuf (~> 3.1)
grpc (~> 1.0)
github-linguist (5.3.3)
......@@ -587,7 +587,7 @@ GEM
orm_adapter (0.5.0)
os (0.9.6)
parallel (1.12.1)
parser (2.5.0.3)
parser (2.5.0.5)
ast (~> 2.4.0)
parslet (1.5.0)
blankslate (~> 2.0)
......@@ -1061,7 +1061,7 @@ DEPENDENCIES
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3)
gitaly-proto (~> 0.91.0)
gitaly-proto (~> 0.94.0)
github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-markup (~> 1.6.2)
......
......@@ -291,7 +291,7 @@ GEM
po_to_json (>= 1.0.0)
rails (>= 3.2.0)
gherkin-ruby (0.3.2)
gitaly-proto (0.91.0)
gitaly-proto (0.94.0)
google-protobuf (~> 3.1)
grpc (~> 1.0)
github-linguist (5.3.3)
......@@ -587,7 +587,7 @@ GEM
orm_adapter (0.5.0)
os (0.9.6)
parallel (1.12.1)
parser (2.5.0.4)
parser (2.5.0.5)
ast (~> 2.4.0)
parslet (1.5.0)
blankslate (~> 2.0)
......@@ -1062,7 +1062,7 @@ DEPENDENCIES
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3)
gitaly-proto (~> 0.91.0)
gitaly-proto (~> 0.94.0)
github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-markup (~> 1.6.2)
......
......@@ -4,7 +4,8 @@ import $ from 'jquery';
import _ from 'underscore';
import Cookies from 'js-cookie';
import { __ } from './locale';
import { isInIssuePage, isInMRPage, isInEpicPage, hasVueMRDiscussionsCookie, updateTooltipTitle } from './lib/utils/common_utils';
import { updateTooltipTitle } from './lib/utils/common_utils';
import { isInVueNoteablePage } from './lib/utils/dom_utils';
import flash from './flash';
import axios from './lib/utils/axios_utils';
......@@ -243,7 +244,7 @@ class AwardsHandler {
addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) {
const isMainAwardsBlock = votesBlock.closest('.js-noteable-awards').length;
if (this.isInVueNoteablePage() && !isMainAwardsBlock) {
if (isInVueNoteablePage() && !isMainAwardsBlock) {
const id = votesBlock.attr('id').replace('note_', '');
this.hideMenuElement($('.emoji-menu'));
......@@ -295,16 +296,8 @@ class AwardsHandler {
}
}
isVueMRDiscussions() {
return isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible');
}
isInVueNoteablePage() {
return isInIssuePage() || isInEpicPage() || this.isVueMRDiscussions();
}
getVotesBlock() {
if (this.isInVueNoteablePage()) {
if (isInVueNoteablePage()) {
const $el = $('.js-add-award.is-active').closest('.note.timeline-entry');
if ($el.length) {
......
......@@ -60,10 +60,6 @@ gl.issueBoards.BoardSidebar = Vue.extend({
this.issue = this.detail.issue;
this.list = this.detail.list;
this.$nextTick(() => {
this.endpoint = this.$refs.assigneeDropdown.dataset.issueUpdate;
});
},
deep: true
},
......@@ -91,7 +87,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({
saveAssignees () {
this.loadingAssignees = true;
gl.issueBoards.BoardsStore.detail.issue.update(this.endpoint)
gl.issueBoards.BoardsStore.detail.issue.update()
.then(() => {
this.loadingAssignees = false;
})
......
......@@ -68,15 +68,6 @@ gl.issueBoards.IssueCardInner = Vue.extend({
return this.issue.assignees.length > this.numberOverLimit;
},
cardUrl() {
let baseUrl = this.issueLinkBase;
if (this.groupId && this.issue.project) {
baseUrl = this.issueLinkBase.replace(':project_path', this.issue.project.path);
}
return `${baseUrl}/${this.issue.iid}`;
},
issueId() {
if (this.issue.iid) {
return `#${this.issue.iid}`;
......@@ -153,13 +144,13 @@ gl.issueBoards.IssueCardInner = Vue.extend({
/>
<a
class="js-no-trigger"
:href="cardUrl"
:href="issue.path"
:title="issue.title">{{ issue.title }}</a>
<span
class="card-number"
v-if="issueId"
>
<template v-if="groupId && issue.project">{{issue.project.path}}</template>{{ issueId }}
{{ issue.referencePath }}
</span>
</h4>
<div class="card-assignee">
......
......@@ -17,14 +17,10 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({
type: Object,
required: true,
},
issueUpdate: {
type: String,
required: true,
},
},
computed: {
updateUrl() {
return this.issueUpdate.replace(':project_path', this.issue.project.path);
return this.issue.path;
},
},
methods: {
......
......@@ -6,6 +6,7 @@ export default class FilteredSearchBoards extends FilteredSearchManager {
constructor(store, updateUrl = false, cantEdit = []) {
super({
page: 'boards',
isGroupDecendent: true,
stateFiltersSelector: '.issues-state-filters',
});
......
......@@ -23,6 +23,8 @@ class ListIssue {
};
this.isLoading = {};
this.sidebarInfoEndpoint = obj.issue_sidebar_endpoint;
this.referencePath = obj.reference_path;
this.path = obj.real_path;
this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint;
this.milestone_id = obj.milestone_id;
this.project_id = obj.project_id;
......@@ -98,7 +100,7 @@ class ListIssue {
this.isLoading[key] = value;
}
update (url) {
update () {
const data = {
issue: {
milestone_id: this.milestone ? this.milestone.id : null,
......@@ -113,7 +115,7 @@ class ListIssue {
}
const projectPath = this.project ? this.project.path : '';
return Vue.http.patch(url.replace(':project_path', projectPath), data);
return Vue.http.patch(`${this.path}.json`, data);
}
}
......
......@@ -3,7 +3,6 @@ import { mapState, mapGetters } from 'vuex';
import ideSidebar from './ide_side_bar.vue';
import ideContextbar from './ide_context_bar.vue';
import repoTabs from './repo_tabs.vue';
import repoFileButtons from './repo_file_buttons.vue';
import ideStatusBar from './ide_status_bar.vue';
import repoEditor from './repo_editor.vue';
......@@ -12,7 +11,6 @@ export default {
ideSidebar,
ideContextbar,
repoTabs,
repoFileButtons,
ideStatusBar,
repoEditor,
},
......@@ -70,9 +68,6 @@ export default {
class="multi-file-edit-pane-content"
:file="activeFile"
/>
<repo-file-buttons
:file="activeFile"
/>
<ide-status-bar
:file="activeFile"
/>
......
<script>
import { __ } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
Icon,
},
directives: {
tooltip,
},
props: {
file: {
type: Object,
required: true,
},
},
computed: {
showButtons() {
return (
this.file.rawPath || this.file.blamePath || this.file.commitsPath || this.file.permalink
);
},
rawDownloadButtonLabel() {
return this.file.binary ? __('Download') : __('Raw');
},
},
};
</script>
<template>
<div
v-if="showButtons"
class="pull-right ide-btn-group"
>
<a
v-tooltip
v-if="!file.binary"
:href="file.blamePath"
:title="__('Blame')"
class="btn btn-xs btn-transparent blame"
>
<icon
name="blame"
:size="16"
/>
</a>
<a
v-tooltip
:href="file.commitsPath"
:title="__('History')"
class="btn btn-xs btn-transparent history"
>
<icon
name="history"
:size="16"
/>
</a>
<a
v-tooltip
:href="file.permalink"
:title="__('Permalink')"
class="btn btn-xs btn-transparent permalink"
>
<icon
name="link"
:size="16"
/>
</a>
<a
v-tooltip
:href="file.rawPath"
target="_blank"
class="btn btn-xs btn-transparent prepend-left-10 raw"
rel="noopener noreferrer"
:title="rawDownloadButtonLabel">
<icon
name="download"
:size="16"
/>
</a>
</div>
</template>
<script>
import icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import timeAgoMixin from '~/vue_shared/mixins/timeago';
import icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import timeAgoMixin from '~/vue_shared/mixins/timeago';
export default {
components: {
icon,
export default {
components: {
icon,
},
directives: {
tooltip,
},
mixins: [timeAgoMixin],
props: {
file: {
type: Object,
required: true,
},
directives: {
tooltip,
},
mixins: [
timeAgoMixin,
],
props: {
file: {
type: Object,
required: true,
},
},
};
},
};
</script>
<template>
......@@ -50,7 +48,9 @@
<div class="text-right">
{{ file.eol }}
</div>
<div class="text-right">
<div
class="text-right"
v-if="!file.binary">
{{ file.editorRow }}:{{ file.editorColumn }}
</div>
<div class="text-right">
......
......@@ -2,10 +2,16 @@
/* global monaco */
import { mapState, mapGetters, mapActions } from 'vuex';
import flash from '~/flash';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import monacoLoader from '../monaco_loader';
import Editor from '../lib/editor';
import IdeFileButtons from './ide_file_buttons.vue';
export default {
components: {
ContentViewer,
IdeFileButtons,
},
props: {
file: {
type: Object,
......@@ -13,11 +19,21 @@ export default {
},
},
computed: {
...mapState(['leftPanelCollapsed', 'rightPanelCollapsed', 'viewer', 'delayViewerUpdated']),
...mapState(['rightPanelCollapsed', 'viewer', 'delayViewerUpdated']),
...mapGetters(['currentMergeRequest', 'getStagedFile']),
shouldHideEditor() {
return this.file && this.file.binary && !this.file.raw;
},
editTabCSS() {
return {
active: this.file.viewMode === 'edit',
};
},
previewTabCSS() {
return {
active: this.file.viewMode === 'preview',
};
},
},
watch: {
file(oldVal, newVal) {
......@@ -26,15 +42,17 @@ export default {
this.initMonaco();
}
},
leftPanelCollapsed() {
this.editor.updateDimensions();
},
rightPanelCollapsed() {
this.editor.updateDimensions();
},
viewer() {
this.createEditorInstance();
},
panelResizing() {
if (!this.panelResizing) {
this.editor.updateDimensions();
}
},
},
beforeDestroy() {
this.editor.dispose();
......@@ -56,6 +74,7 @@ export default {
'changeFileContent',
'setFileLanguage',
'setEditorPosition',
'setFileViewMode',
'setFileEOL',
'updateViewer',
'updateDelayViewerUpdated',
......@@ -157,16 +176,49 @@ export default {
id="ide"
class="blob-viewer-container blob-editor-container"
>
<div
v-if="shouldHideEditor"
v-html="file.html"
>
<div class="ide-mode-tabs clearfix">
<ul
class="nav-links pull-left"
v-if="!shouldHideEditor">
<li :class="editTabCSS">
<a
href="javascript:void(0);"
role="button"
@click.prevent="setFileViewMode({ file, viewMode: 'edit' })">
<template v-if="viewer === 'editor'">
{{ __('Edit') }}
</template>
<template v-else>
{{ __('Review') }}
</template>
</a>
</li>
<li
v-if="file.previewMode"
:class="previewTabCSS">
<a
href="javascript:void(0);"
role="button"
@click.prevent="setFileViewMode({ file, viewMode:'preview' })">
{{ file.previewMode.previewTitle }}
</a>
</li>
</ul>
<ide-file-buttons
:file="file"
/>
</div>
<div
v-show="!shouldHideEditor"
v-show="!shouldHideEditor && file.viewMode === 'edit'"
ref="editor"
class="multi-file-editor-holder"
>
</div>
<content-viewer
v-if="shouldHideEditor || file.viewMode === 'preview'"
:content="file.content || file.raw"
:path="file.rawPath"
:file-size="file.size"
:project-path="file.projectId"/>
</div>
</template>
<script>
export default {
props: {
file: {
type: Object,
required: true,
},
},
computed: {
showButtons() {
return this.file.rawPath ||
this.file.blamePath ||
this.file.commitsPath ||
this.file.permalink;
},
rawDownloadButtonLabel() {
return this.file.binary ? 'Download' : 'Raw';
},
},
};
</script>
<template>
<div
v-if="showButtons"
class="multi-file-editor-btn-group"
>
<a
:href="file.rawPath"
target="_blank"
class="btn btn-default btn-sm raw"
rel="noopener noreferrer">
{{ rawDownloadButtonLabel }}
</a>
<div
class="btn-group"
role="group"
aria-label="File actions"
>
<a
:href="file.blamePath"
class="btn btn-default btn-sm blame"
>
Blame
</a>
<a
:href="file.commitsPath"
class="btn btn-default btn-sm history"
>
History
</a>
<a
:href="file.permalink"
class="btn btn-default btn-sm permalink"
>
Permalink
</a>
</div>
</div>
</template>
<script>
import { mapActions, mapState } from 'vuex';
import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
import { mapActions, mapState } from 'vuex';
import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
export default {
components: {
PanelResizer,
export default {
components: {
PanelResizer,
},
props: {
collapsible: {
type: Boolean,
required: true,
},
props: {
collapsible: {
type: Boolean,
required: true,
},
initialWidth: {
type: Number,
required: true,
},
minSize: {
type: Number,
required: false,
default: 200,
},
side: {
type: String,
required: true,
},
initialWidth: {
type: Number,
required: true,
},
data() {
return {
width: this.initialWidth,
};
minSize: {
type: Number,
required: false,
default: 340,
},
computed: {
...mapState({
collapsed(state) {
return state[`${this.side}PanelCollapsed`];
},
}),
panelStyle() {
if (!this.collapsed) {
return {
width: `${this.width}px`,
};
}
return {};
},
side: {
type: String,
required: true,
},
methods: {
...mapActions([
'setPanelCollapsedStatus',
'setResizingStatus',
]),
toggleFullbarCollapsed() {
if (this.collapsed && this.collapsible) {
this.setPanelCollapsedStatus({
side: this.side,
collapsed: !this.collapsed,
});
}
},
data() {
return {
width: this.initialWidth,
};
},
computed: {
...mapState({
collapsed(state) {
return state[`${this.side}PanelCollapsed`];
},
}),
panelStyle() {
if (!this.collapsed) {
return {
width: `${this.width}px`,
};
}
return {};
},
},
methods: {
...mapActions(['setPanelCollapsedStatus', 'setResizingStatus']),
toggleFullbarCollapsed() {
if (this.collapsed && this.collapsible) {
this.setPanelCollapsedStatus({
side: this.side,
collapsed: !this.collapsed,
});
}
},
maxSize: (window.innerWidth / 2),
};
},
maxSize: window.innerWidth / 2,
};
</script>
<template>
......
......@@ -69,6 +69,7 @@ export default class Editor {
occurrencesHighlight: false,
renderLineHighlight: 'none',
hideCursorInOverviewRuler: true,
renderSideBySide: Editor.renderSideBySide(domElement),
})),
);
......@@ -81,7 +82,7 @@ export default class Editor {
}
attachModel(model) {
if (this.instance.getEditorType() === 'vs.editor.IDiffEditor') {
if (this.isDiffEditorType) {
this.instance.setModel({
original: model.getOriginalModel(),
modified: model.getModel(),
......@@ -153,6 +154,7 @@ export default class Editor {
updateDimensions() {
this.instance.layout();
this.updateDiffView();
}
setPosition({ lineNumber, column }) {
......@@ -171,4 +173,20 @@ export default class Editor {
this.disposable.add(this.instance.onDidChangeCursorPosition(e => cb(this.instance, e)));
}
updateDiffView() {
if (!this.isDiffEditorType) return;
this.instance.updateOptions({
renderSideBySide: Editor.renderSideBySide(this.instance.getDomNode()),
});
}
get isDiffEditorType() {
return this.instance.getEditorType() === 'vs.editor.IDiffEditor';
}
static renderSideBySide(domElement) {
return domElement.offsetWidth >= 700;
}
}
......@@ -6,7 +6,7 @@ export const defaultEditorOptions = {
minimap: {
enabled: false,
},
wordWrap: 'bounded',
wordWrap: 'on',
};
export default [
......
......@@ -152,6 +152,10 @@ export const setEditorPosition = ({ getters, commit }, { editorRow, editorColumn
}
};
export const setFileViewMode = ({ state, commit }, { file, viewMode }) => {
commit(types.SET_FILE_VIEWMODE, { file, viewMode });
};
export const discardFileChanges = ({ state, commit }, path) => {
const file = state.entries[path];
......
......@@ -38,6 +38,7 @@ export const SET_FILE_BASE_RAW_DATA = 'SET_FILE_BASE_RAW_DATA';
export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT';
export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE';
export const SET_FILE_POSITION = 'SET_FILE_POSITION';
export const SET_FILE_VIEWMODE = 'SET_FILE_VIEWMODE';
export const SET_FILE_EOL = 'SET_FILE_EOL';
export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES';
export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED';
......
......@@ -42,6 +42,8 @@ export default {
renderError: data.render_error,
raw: null,
baseRaw: null,
html: data.html,
size: data.size,
});
},
[types.SET_FILE_RAW_DATA](state, { file, raw }) {
......@@ -83,6 +85,11 @@ export default {
mrChange,
});
},
[types.SET_FILE_VIEWMODE](state, { file, viewMode }) {
Object.assign(state.entries[file.path], {
viewMode,
});
},
[types.DISCARD_FILE_CHANGES](state, path) {
Object.assign(state.entries[path], {
content: state.entries[path].raw,
......
......@@ -39,6 +39,9 @@ export const dataStructure = () => ({
editorColumn: 1,
fileLanguage: '',
eol: '',
viewMode: 'edit',
previewMode: null,
size: 0,
});
export const decorateData = entity => {
......@@ -58,8 +61,9 @@ export const decorateData = entity => {
changed = false,
parentTreeUrl = '',
base64 = false,
previewMode,
file_lock,
html,
} = entity;
return {
......@@ -80,8 +84,9 @@ export const decorateData = entity => {
renderError,
content,
base64,
previewMode,
file_lock,
html,
};
};
......
import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
import { decorateData, sortTree } from '../utils';
self.addEventListener('message', e => {
const {
data,
projectId,
branchId,
tempFile = false,
content = '',
base64 = false,
} = e.data;
const { data, projectId, branchId, tempFile = false, content = '', base64 = false } = e.data;
const treeList = [];
let file;
......@@ -19,9 +13,7 @@ self.addEventListener('message', e => {
if (pathSplit.length > 0) {
pathSplit.reduce((pathAcc, folderName) => {
const parentFolder = acc[pathAcc[pathAcc.length - 1]];
const folderPath = `${
parentFolder ? `${parentFolder.path}/` : ''
}${folderName}`;
const folderPath = `${parentFolder ? `${parentFolder.path}/` : ''}${folderName}`;
const foundEntry = acc[folderPath];
if (!foundEntry) {
......@@ -33,9 +25,7 @@ self.addEventListener('message', e => {
path: folderPath,
url: `/${projectId}/tree/${branchId}/${folderPath}/`,
type: 'tree',
parentTreeUrl: parentFolder
? parentFolder.url
: `/${projectId}/tree/${branchId}/`,
parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`,
tempFile,
changed: tempFile,
opened: tempFile,
......@@ -70,13 +60,12 @@ self.addEventListener('message', e => {
path,
url: `/${projectId}/blob/${branchId}/${path}`,
type: 'blob',
parentTreeUrl: fileFolder
? fileFolder.url
: `/${projectId}/blob/${branchId}`,
parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`,
tempFile,
changed: tempFile,
content,
base64,
previewMode: viewerInformationForPath(blobName),
});
Object.assign(acc, {
......
/* eslint-disable import/prefer-default-export */
import $ from 'jquery';
import { isInIssuePage, isInMRPage, isInEpicPage, hasVueMRDiscussionsCookie } from './common_utils';
const isVueMRDiscussions = () => isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible');
export const addClassIfElementExists = (element, className) => {
if (element) {
element.classList.add(className);
}
};
export const isInVueNoteablePage = () => isInIssuePage() || isInEpicPage() || isVueMRDiscussions();
......@@ -7,7 +7,8 @@
* @param {String} text
* @returns {String}
*/
export const addDelimiter = text => (text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : text);
export const addDelimiter = text =>
(text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : text);
/**
* Returns '99+' for numbers bigger than 99.
......@@ -22,7 +23,8 @@ export const highCountTrim = count => (count > 99 ? '99+' : count);
* @param {String} string
* @requires {String}
*/
export const humanize = string => string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
export const humanize = string =>
string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
/**
* Adds an 's' to the end of the string when count is bigger than 0
......@@ -53,7 +55,7 @@ export const slugify = str => str.trim().toLowerCase();
* @param {Number} maxLength
* @returns {String}
*/
export const truncate = (string, maxLength) => `${string.substr(0, (maxLength - 3))}...`;
export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3)}...`;
/**
* Capitalizes first character
......@@ -80,3 +82,15 @@ export const stripHtml = (string, replace = '') => string.replace(/<[^>]*>/g, re
* @param {*} string
*/
export const convertToCamelCase = string => string.replace(/(_\w)/g, s => s[1].toUpperCase());
/**
* Converts a sentence to lower case from the second word onwards
* e.g. Hello World => Hello world
*
* @param {*} string
*/
export const convertToSentenceCase = string => {
const splitWord = string.split(' ').map((word, index) => (index > 0 ? word.toLowerCase() : word));
return splitWord.join(' ');
};
......@@ -94,10 +94,10 @@ export default class MilestoneSelect {
if (showMenuAbove) {
$dropdown.data('glDropdown').positionMenuAbove();
}
$(`[data-milestone-id="${selectedMilestone}"] > a`).addClass('is-active');
$(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`).addClass('is-active');
}),
renderRow: milestone => `
<li data-milestone-id="${milestone.name}">
<li data-milestone-id="${_.escape(milestone.name)}">
<a href='#' class='dropdown-menu-milestone-link'>
${_.escape(milestone.title)}
</a>
......@@ -125,7 +125,6 @@ export default class MilestoneSelect {
return milestone.id;
}
},
isSelected: milestone => milestone.name === selectedMilestone,
hidden: () => {
$selectBox.hide();
// display:block overrides the hide-collapse rule
......@@ -137,7 +136,7 @@ export default class MilestoneSelect {
selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault;
}
$('a.is-active', $el).removeClass('is-active');
$(`[data-milestone-id="${selectedMilestone}"] > a`, $el).addClass('is-active');
$(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`, $el).addClass('is-active');
},
vue: $dropdown.hasClass('js-issue-board-sidebar'),
clicked: (clickEvent) => {
......@@ -158,6 +157,7 @@ export default class MilestoneSelect {
const isMRIndex = (page === page && page === 'projects:merge_requests:index');
const isSelecting = (selected.name !== selectedMilestone);
selectedMilestone = isSelecting ? selected.name : selectedMilestoneDefault;
if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) {
e.preventDefault();
return;
......
<script>
import { scaleLinear, scaleTime } from 'd3-scale';
import { axisLeft, axisBottom } from 'd3-axis';
import _ from 'underscore';
import { max, extent } from 'd3-array';
import { select } from 'd3-selection';
import GraphAxis from './graph/axis.vue';
import GraphLegend from './graph/legend.vue';
import GraphFlag from './graph/flag.vue';
import GraphDeployment from './graph/deployment.vue';
......@@ -18,10 +20,11 @@ const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select }
export default {
components: {
GraphLegend,
GraphAxis,
GraphFlag,
GraphDeployment,
GraphPath,
GraphLegend,
},
mixins: [MonitoringMixin],
props: {
......@@ -138,7 +141,7 @@ export default {
this.legendTitle = query.label || 'Average';
this.graphWidth = this.$refs.baseSvg.clientWidth - this.margin.left - this.margin.right;
this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom;
this.baseGraphHeight = this.graphHeight;
this.baseGraphHeight = this.graphHeight - 50;
this.baseGraphWidth = this.graphWidth;
// pixel offsets inside the svg and outside are not 1:1
......@@ -177,10 +180,8 @@ export default {
this.graphHeightOffset,
);
if (!this.showLegend) {
this.baseGraphHeight -= 50;
} else if (this.timeSeries.length > 3) {
this.baseGraphHeight = this.baseGraphHeight += (this.timeSeries.length - 3) * 20;
if (_.findWhere(this.timeSeries, { renderCanary: true })) {
this.timeSeries = this.timeSeries.map(series => ({ ...series, renderCanary: true }));
}
const axisXScale = d3.scaleTime().range([0, this.graphWidth - 70]);
......@@ -251,17 +252,13 @@ export default {
class="y-axis"
transform="translate(70, 20)"
/>
<graph-legend
<graph-axis
:graph-width="graphWidth"
:graph-height="graphHeight"
:margin="margin"
:measurements="measurements"
:legend-title="legendTitle"
:y-axis-label="yAxisLabel"
:time-series="timeSeries"
:unit-of-display="unitOfDisplay"
:current-data-index="currentDataIndex"
:show-legend-group="showLegend"
/>
<svg
class="graph-data"
......@@ -306,5 +303,10 @@ export default {
:deployment-flag-data="deploymentFlagData"
/>
</div>
<graph-legend
v-if="showLegend"
:legend-title="legendTitle"
:time-series="timeSeries"
/>
</div>
</template>
<script>
import { convertToSentenceCase } from '~/lib/utils/text_utility';
import { s__ } from '~/locale';
export default {
props: {
graphWidth: {
type: Number,
required: true,
},
graphHeight: {
type: Number,
required: true,
},
margin: {
type: Object,
required: true,
},
measurements: {
type: Object,
required: true,
},
yAxisLabel: {
type: String,
required: true,
},
unitOfDisplay: {
type: String,
required: true,
},
},
data() {
return {
yLabelWidth: 0,
yLabelHeight: 0,
};
},
computed: {
textTransform() {
const yCoordinate =
(this.graphHeight -
this.margin.top +
this.measurements.axisLabelLineOffset) /
2 || 0;
return `translate(15, ${yCoordinate}) rotate(-90)`;
},
rectTransform() {
const yCoordinate =
(this.graphHeight -
this.margin.top +
this.measurements.axisLabelLineOffset) /
2 +
this.yLabelWidth / 2 || 0;
return `translate(0, ${yCoordinate}) rotate(-90)`;
},
xPosition() {
return (
(this.graphWidth + this.measurements.axisLabelLineOffset) / 2 -
this.margin.right || 0
);
},
yPosition() {
return (
this.graphHeight -
this.margin.top +
this.measurements.axisLabelLineOffset || 0
);
},
yAxisLabelSentenceCase() {
return `${convertToSentenceCase(this.yAxisLabel)} (${this.unitOfDisplay})`;
},
timeString() {
return s__('PrometheusDashboard|Time');
},
},
mounted() {
this.$nextTick(() => {
const bbox = this.$refs.ylabel.getBBox();
this.yLabelWidth = bbox.width + 10; // Added some padding
this.yLabelHeight = bbox.height + 5;
});
},
};
</script>
<template>
<g class="axis-label-container">
<line
class="label-x-axis-line"
stroke="#000000"
stroke-width="1"
x1="10"
:y1="yPosition"
:x2="graphWidth + 20"
:y2="yPosition"
/>
<line
class="label-y-axis-line"
stroke="#000000"
stroke-width="1"
x1="10"
y1="0"
:x2="10"
:y2="yPosition"
/>
<rect
class="rect-axis-text"
:transform="rectTransform"
:width="yLabelWidth"
:height="yLabelHeight"
/>
<text
class="label-axis-text y-label-text"
text-anchor="middle"
:transform="textTransform"
ref="ylabel"
>
{{ yAxisLabelSentenceCase }}
</text>
<rect
class="rect-axis-text"
:x="xPosition + 60"
:y="graphHeight - 80"
width="35"
height="50"
/>
<text
class="label-axis-text x-label-text"
:x="xPosition + 60"
:y="yPosition"
dy=".35em"
>
{{ timeString }}
</text>
</g>
</template>
<script>
import { dateFormat, timeFormat } from '../../utils/date_time_formatters';
import { formatRelevantDigits } from '../../../lib/utils/number_utils';
import icon from '../../../vue_shared/components/icon.vue';
import Icon from '../../../vue_shared/components/icon.vue';
import TrackLine from './track_line.vue';
export default {
components: {
icon,
Icon,
TrackLine,
},
props: {
currentXCoordinate: {
......@@ -107,11 +109,6 @@ export default {
}
return `series ${index + 1}`;
},
strokeDashArray(type) {
if (type === 'dashed') return '6, 3';
if (type === 'dotted') return '3, 3';
return null;
},
},
};
</script>
......@@ -160,28 +157,13 @@ export default {
</div>
</div>
<div class="popover-content">
<table>
<table class="prometheus-table">
<tr
v-for="(series, index) in timeSeries"
:key="index"
>
<td>
<svg
width="15"
height="6"
>
<line
:stroke="series.lineColor"
:stroke-dasharray="strokeDashArray(series.lineStyle)"
stroke-width="4"
x1="0"
x2="15"
y1="2"
y2="2"
/>
</svg>
</td>
<td>{{ seriesMetricLabel(index, series) }}</td>
<track-line :track="series"/>
<td>{{ series.track }} {{ seriesMetricLabel(index, series) }}</td>
<td>
<strong>{{ seriesMetricValue(series) }}</strong>
</td>
......
<script>
import { formatRelevantDigits } from '../../../lib/utils/number_utils';
import TrackLine from './track_line.vue';
import TrackInfo from './track_info.vue';
export default {
components: {
TrackLine,
TrackInfo,
},
props: {
graphWidth: {
type: Number,
required: true,
},
graphHeight: {
type: Number,
required: true,
},
margin: {
type: Object,
required: true,
},
measurements: {
type: Object,
required: true,
},
legendTitle: {
type: String,
required: true,
},
yAxisLabel: {
type: String,
required: true,
},
timeSeries: {
type: Array,
required: true,
},
unitOfDisplay: {
type: String,
required: true,
},
currentDataIndex: {
type: Number,
required: true,
},
showLegendGroup: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
yLabelWidth: 0,
yLabelHeight: 0,
seriesXPosition: 0,
metricUsageXPosition: 0,
};
},
computed: {
textTransform() {
const yCoordinate =
(this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 || 0;
return `translate(15, ${yCoordinate}) rotate(-90)`;
},
rectTransform() {
const yCoordinate =
(this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 +
this.yLabelWidth / 2 || 0;
return `translate(0, ${yCoordinate}) rotate(-90)`;
},
xPosition() {
return (this.graphWidth + this.measurements.axisLabelLineOffset) / 2 - this.margin.right || 0;
},
yPosition() {
return this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset || 0;
},
},
mounted() {
this.$nextTick(() => {
const bbox = this.$refs.ylabel.getBBox();
this.metricUsageXPosition = 0;
this.seriesXPosition = 0;
if (this.$refs.legendTitleSvg != null) {
this.seriesXPosition = this.$refs.legendTitleSvg[0].getBBox().width;
}
if (this.$refs.seriesTitleSvg != null) {
this.metricUsageXPosition = this.$refs.seriesTitleSvg[0].getBBox().width;
}
this.yLabelWidth = bbox.width + 10; // Added some padding
this.yLabelHeight = bbox.height + 5;
});
},
methods: {
translateLegendGroup(index) {
return `translate(0, ${12 * index})`;
},
formatMetricUsage(series) {
const value =
series.values[this.currentDataIndex] && series.values[this.currentDataIndex].value;
if (isNaN(value)) {
return '-';
}
return `${formatRelevantDigits(value)} ${this.unitOfDisplay}`;
},
createSeriesString(index, series) {
if (series.metricTag) {
return `${series.metricTag} ${this.formatMetricUsage(series)}`;
}
return `${this.legendTitle} series ${index + 1} ${this.formatMetricUsage(series)}`;
},
strokeDashArray(type) {
if (type === 'dashed') return '6, 3';
if (type === 'dotted') return '3, 3';
return null;
isStable(track) {
return {
'prometheus-table-row-highlight': track.trackName !== 'Canary' && track.renderCanary,
};
},
},
};
</script>
<template>
<g class="axis-label-container">
<line
class="label-x-axis-line"
stroke="#000000"
stroke-width="1"
x1="10"
:y1="yPosition"
:x2="graphWidth + 20"
:y2="yPosition"
/>
<line
class="label-y-axis-line"
stroke="#000000"
stroke-width="1"
x1="10"
y1="0"
:x2="10"
:y2="yPosition"
/>
<rect
class="rect-axis-text"
:transform="rectTransform"
:width="yLabelWidth"
:height="yLabelHeight"
/>
<text
class="label-axis-text y-label-text"
text-anchor="middle"
:transform="textTransform"
ref="ylabel"
>
{{ yAxisLabel }}
</text>
<rect
class="rect-axis-text"
:x="xPosition + 60"
:y="graphHeight - 80"
width="35"
height="50"
/>
<text
class="label-axis-text x-label-text"
:x="xPosition + 60"
:y="yPosition"
dy=".35em"
>
Time
</text>
<template v-if="showLegendGroup">
<g
class="legend-group"
<div class="prometheus-graph-legends prepend-left-10">
<table class="prometheus-table">
<tr
v-for="(series, index) in timeSeries"
:key="index"
:transform="translateLegendGroup(index)"
v-if="series.shouldRenderLegend"
:class="isStable(series)"
>
<line
:stroke="series.lineColor"
:stroke-width="measurements.legends.height"
:stroke-dasharray="strokeDashArray(series.lineStyle)"
:x1="measurements.legends.offsetX"
:x2="measurements.legends.offsetX + measurements.legends.width"
:y1="graphHeight - measurements.legends.offsetY"
:y2="graphHeight - measurements.legends.offsetY"
/>
<text
v-if="timeSeries.length > 1"
class="legend-metric-title"
ref="legendTitleSvg"
x="38"
:y="graphHeight - 30"
>
{{ createSeriesString(index, series) }}
</text>
<text
v-else
<td>
<strong v-if="series.renderCanary">{{ series.trackName }}</strong>
</td>
<track-line :track="series" />
<td
class="legend-metric-title"
ref="legendTitleSvg"
x="38"
:y="graphHeight - 30"
>
{{ legendTitle }} {{ formatMetricUsage(series) }}
</text>
</g>
</template>
</g>
v-if="timeSeries.length > 1">
<track-info
:track="series"
v-if="series.metricTag" />
<track-info
v-else
:track="series">
<strong>{{ legendTitle }}</strong> series {{ index + 1 }}
</track-info>
</td>
<td v-else>
<track-info :track="series">
<strong>{{ legendTitle }}</strong>
</track-info>
</td>
<template v-for="(track, trackIndex) in series.tracksLegend">
<track-line
:track="track"
:key="`track-line-${trackIndex}`"/>
<td :key="`track-info-${trackIndex}`">
<track-info
class="legend-metric-title"
:track="track" />
</td>
</template>
</tr>
</table>
</div>
</template>
<script>
import { formatRelevantDigits } from '~/lib/utils/number_utils';
export default {
name: 'TrackInfo',
props: {
track: {
type: Object,
required: true,
},
},
computed: {
summaryMetrics() {
return `Avg: ${formatRelevantDigits(this.track.average)} · Max: ${formatRelevantDigits(
this.track.max,
)}`;
},
},
};
</script>
<template>
<span>
<slot>
<strong> {{ track.metricTag }} </strong>
</slot>
{{ summaryMetrics }}
</span>
</template>
<script>
export default {
name: 'TrackLine',
props: {
track: {
type: Object,
required: true,
},
},
computed: {
stylizedLine() {
if (this.track.lineStyle === 'dashed') return '6, 3';
if (this.track.lineStyle === 'dotted') return '3, 3';
return null;
},
},
};
</script>
<template>
<td>
<svg
width="15"
height="6">
<line
:stroke-dasharray="stylizedLine"
:stroke="track.lineColor"
stroke-width="4"
:x1="0"
:x2="15"
:y1="2"
:y2="2"
/>
</svg>
</td>
</template>
import _ from 'underscore';
function sortMetrics(metrics) {
return _.chain(metrics).sortBy('weight').sortBy('title').value();
return _.chain(metrics).sortBy('title').sortBy('weight').value();
}
function normalizeMetrics(metrics) {
......
import _ from 'underscore';
import { scaleLinear, scaleTime } from 'd3-scale';
import { line, area, curveLinear } from 'd3-shape';
import { extent, max } from 'd3-array';
import { extent, max, sum } from 'd3-array';
import { timeMinute } from 'd3-time';
const d3 = { scaleLinear, scaleTime, line, area, curveLinear, extent, max, timeMinute };
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
const d3 = {
scaleLinear,
scaleTime,
line,
area,
curveLinear,
extent,
max,
timeMinute,
sum,
};
const defaultColorPalette = {
blue: ['#1f78d1', '#8fbce8'],
......@@ -20,6 +31,8 @@ const defaultStyleOrder = ['solid', 'dashed', 'dotted'];
function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom, yDom, lineStyle) {
let usedColors = [];
let renderCanary = false;
const timeSeriesParsed = [];
function pickColor(name) {
let pick;
......@@ -38,16 +51,23 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom
return defaultColorPalette[pick];
}
return query.result.map((timeSeries, timeSeriesNumber) => {
query.result.forEach((timeSeries, timeSeriesNumber) => {
let metricTag = '';
let lineColor = '';
let areaColor = '';
let shouldRenderLegend = true;
const timeSeriesValues = timeSeries.values.map(d => d.value);
const maximumValue = d3.max(timeSeriesValues);
const accum = d3.sum(timeSeriesValues);
const trackName = capitalizeFirstCharacter(query.track ? query.track : 'Stable');
if (trackName === 'Canary') {
renderCanary = true;
}
const timeSeriesScaleX = d3.scaleTime()
.range([0, graphWidth - 70]);
const timeSeriesScaleX = d3.scaleTime().range([0, graphWidth - 70]);
const timeSeriesScaleY = d3.scaleLinear()
.range([graphHeight - graphHeightOffset, 0]);
const timeSeriesScaleY = d3.scaleLinear().range([graphHeight - graphHeightOffset, 0]);
timeSeriesScaleX.domain(xDom);
timeSeriesScaleX.ticks(d3.timeMinute, 60);
......@@ -55,13 +75,15 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom
const defined = d => !isNaN(d.value) && d.value != null;
const lineFunction = d3.line()
const lineFunction = d3
.line()
.defined(defined)
.curve(d3.curveLinear) // d3 v4 uses curbe instead of interpolate
.x(d => timeSeriesScaleX(d.time))
.y(d => timeSeriesScaleY(d.value));
const areaFunction = d3.area()
const areaFunction = d3
.area()
.defined(defined)
.curve(d3.curveLinear)
.x(d => timeSeriesScaleX(d.time))
......@@ -69,38 +91,62 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom
.y1(d => timeSeriesScaleY(d.value));
const timeSeriesMetricLabel = timeSeries.metric[Object.keys(timeSeries.metric)[0]];
const seriesCustomizationData = query.series != null &&
_.findWhere(query.series[0].when, { value: timeSeriesMetricLabel });
const seriesCustomizationData =
query.series != null && _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel });
if (seriesCustomizationData) {
metricTag = seriesCustomizationData.value || timeSeriesMetricLabel;
[lineColor, areaColor] = pickColor(seriesCustomizationData.color);
shouldRenderLegend = false;
} else {
metricTag = timeSeriesMetricLabel || query.label || `series ${timeSeriesNumber + 1}`;
[lineColor, areaColor] = pickColor();
if (timeSeriesParsed.length > 1) {
shouldRenderLegend = false;
}
}
if (query.track) {
metricTag += ` - ${query.track}`;
if (!shouldRenderLegend) {
if (!timeSeriesParsed[0].tracksLegend) {
timeSeriesParsed[0].tracksLegend = [];
}
timeSeriesParsed[0].tracksLegend.push({
max: maximumValue,
average: accum / timeSeries.values.length,
lineStyle,
lineColor,
metricTag,
});
}
return {
timeSeriesParsed.push({
linePath: lineFunction(timeSeries.values),
areaPath: areaFunction(timeSeries.values),
timeSeriesScaleX,
values: timeSeries.values,
max: maximumValue,
average: accum / timeSeries.values.length,
lineStyle,
lineColor,
areaColor,
metricTag,
};
trackName,
shouldRenderLegend,
renderCanary,
});
});
return timeSeriesParsed;
}
export default function createTimeSeries(queries, graphWidth, graphHeight, graphHeightOffset) {
const allValues = queries.reduce((allQueryResults, query) => allQueryResults.concat(
query.result.reduce((allResults, result) => allResults.concat(result.values), []),
), []);
const allValues = queries.reduce(
(allQueryResults, query) =>
allQueryResults.concat(
query.result.reduce((allResults, result) => allResults.concat(result.values), []),
),
[],
);
const xDom = d3.extent(allValues, d => d.time);
const yDom = [0, d3.max(allValues.map(d => d.value))];
......
......@@ -13,8 +13,11 @@ export default function initMrNotes() {
data() {
const notesDataset = document.getElementById('js-vue-mr-discussions')
.dataset;
const noteableData = JSON.parse(notesDataset.noteableData);
noteableData.noteableType = notesDataset.noteableType;
return {
noteableData: JSON.parse(notesDataset.noteableData),
noteableData,
currentUserData: JSON.parse(notesDataset.currentUserData),
notesData: JSON.parse(notesDataset.notesData),
};
......
......@@ -1190,12 +1190,12 @@ export default class Notes {
addForm = false;
let lineTypeSelector = '';
rowCssToAdd =
'<tr class="notes_holder js-temp-notes-holder"><td class="notes_line" colspan="2"></td><td class="notes_content"><div class="content"></div></td></tr>';
'<tr class="notes_holder js-temp-notes-holder"><td class="notes_line" colspan="2"></td><td class="notes_content"><div class="content discussion-notes"></div></td></tr>';
// In parallel view, look inside the correct left/right pane
if (this.isParallelView()) {
lineTypeSelector = `.${lineType}`;
rowCssToAdd =
'<tr class="notes_holder js-temp-notes-holder"><td class="notes_line old"></td><td class="notes_content parallel old"><div class="content"></div></td><td class="notes_line new"></td><td class="notes_content parallel new"><div class="content"></div></td></tr>';
'<tr class="notes_holder js-temp-notes-holder"><td class="notes_line old"></td><td class="notes_content parallel old"><div class="content discussion-notes"></div></td><td class="notes_line new"></td><td class="notes_content parallel new"><div class="content discussion-notes"></div></td></tr>';
}
const notesContentSelector = `.notes_content${lineTypeSelector} .content`;
let notesContent = targetRow.find(notesContentSelector);
......
......@@ -258,9 +258,7 @@ Please check your network connection and try again.`;
:key="note.id"
/>
</ul>
<div
:class="{ 'is-replying': isReplying }"
class="discussion-reply-holder">
<div class="discussion-reply-holder">
<template v-if="!isReplying && canReply">
<div
class="btn-group-justified discussion-with-resolve-btn"
......
......@@ -49,16 +49,7 @@ export default {
computed: {
...mapGetters(['notes', 'getNotesDataByProp', 'discussionCount']),
noteableType() {
// FIXME -- @fatihacet Get this from JSON data.
const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE, EPIC_NOTEABLE_TYPE } = constants;
if (this.noteableData.noteableType === EPIC_NOTEABLE_TYPE) {
return EPIC_NOTEABLE_TYPE;
}
return this.noteableData.merge_params
? MERGE_REQUEST_NOTEABLE_TYPE
: ISSUE_NOTEABLE_TYPE;
return this.noteableData.noteableType;
},
allNotes() {
if (this.isLoading) {
......
......@@ -14,3 +14,9 @@ export const EPIC_NOTEABLE_TYPE = 'epic';
export const MERGE_REQUEST_NOTEABLE_TYPE = 'merge_request';
export const UNRESOLVE_NOTE_METHOD_NAME = 'delete';
export const RESOLVE_NOTE_METHOD_NAME = 'post';
export const NOTEABLE_TYPE_MAPPING = {
Issue: ISSUE_NOTEABLE_TYPE,
MergeRequest: MERGE_REQUEST_NOTEABLE_TYPE,
Epic: EPIC_NOTEABLE_TYPE,
};
......@@ -9,16 +9,7 @@ export default {
},
computed: {
noteableType() {
switch (this.note.noteable_type) {
case 'MergeRequest':
return constants.MERGE_REQUEST_NOTEABLE_TYPE;
case 'Issue':
return constants.ISSUE_NOTEABLE_TYPE;
case 'Epic':
return constants.EPIC_NOTEABLE_TYPE;
default:
return '';
}
return constants.NOTEABLE_TYPE_MAPPING[this.note.noteable_type];
},
},
};
<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.
*/
export default {
components: {
icon,
},
import $ from 'jquery';
import tooltip from '../../../vue_shared/directives/tooltip';
import Icon from '../../../vue_shared/components/icon.vue';
import { dasherize } from '../../../lib/utils/text_utility';
import eventHub from '../../event_hub';
/**
* Renders either a cancel, retry or play icon pointing to the given path.
*/
export default {
components: {
Icon,
},
directives: {
tooltip,
},
directives: {
tooltip,
},
props: {
tooltipText: {
type: String,
required: true,
},
props: {
tooltipText: {
type: String,
required: true,
},
link: {
type: String,
required: true,
},
link: {
type: String,
required: true,
},
actionMethod: {
type: String,
required: true,
},
actionIcon: {
type: String,
required: true,
},
actionIcon: {
type: String,
required: true,
},
buttonDisabled: {
type: String,
required: false,
default: null,
},
},
computed: {
cssClass() {
const actionIconDash = dasherize(this.actionIcon);
return `${actionIconDash} js-icon-${actionIconDash}`;
},
isDisabled() {
return this.buttonDisabled === this.link;
},
},
computed: {
cssClass() {
const actionIconDash = dasherize(this.actionIcon);
return `${actionIconDash} js-icon-${actionIconDash}`;
},
methods: {
onClickAction() {
$(this.$el).tooltip('hide');
eventHub.$emit('graphAction', this.link);
},
};
},
};
</script>
<template>
<a
<button
type="button"
@click="onClickAction"
v-tooltip
:data-method="actionMethod"
:title="tooltipText"
:href="link"
class="ci-action-icon-container ci-action-icon-wrapper"
class="btn btn-blank btn-transparent ci-action-icon-container ci-action-icon-wrapper"
:class="cssClass"
data-container="body"
:disabled="isDisabled"
>
<icon :name="actionIcon" />
</a>
</button>
</template>
<script>
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import stageColumnComponent from './stage_column_component.vue';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import StageColumnComponent from './stage_column_component.vue';
export default {
components: {
stageColumnComponent,
loadingIcon,
},
export default {
components: {
StageColumnComponent,
LoadingIcon,
},
props: {
isLoading: {
type: Boolean,
required: true,
},
pipeline: {
type: Object,
required: true,
},
props: {
isLoading: {
type: Boolean,
required: true,
},
pipeline: {
type: Object,
required: true,
},
actionDisabled: {
type: String,
required: false,
default: null,
},
},
computed: {
graph() {
return this.pipeline.details && this.pipeline.details.stages;
},
computed: {
graph() {
return this.pipeline.details && this.pipeline.details.stages;
},
},
methods: {
capitalizeStageName(name) {
return name.charAt(0).toUpperCase() + name.slice(1);
},
methods: {
capitalizeStageName(name) {
return name.charAt(0).toUpperCase() + name.slice(1);
},
isFirstColumn(index) {
return index === 0;
},
isFirstColumn(index) {
return index === 0;
},
stageConnectorClass(index, stage) {
let className;
stageConnectorClass(index, stage) {
let className;
// If it's the first stage column and only has one job
if (index === 0 && stage.groups.length === 1) {
className = 'no-margin';
} else if (index > 0) {
// If it is not the first column
className = 'left-margin';
}
// If it's the first stage column and only has one job
if (index === 0 && stage.groups.length === 1) {
className = 'no-margin';
} else if (index > 0) {
// If it is not the first column
className = 'left-margin';
}
return className;
},
return className;
},
};
},
};
</script>
<template>
<div class="build-content middle-block js-pipeline-graph">
......@@ -70,6 +75,7 @@
:key="stage.name"
:stage-connector-class="stageConnectorClass(index, stage)"
:is-first-column="isFirstColumn(index)"
:action-disabled="actionDisabled"
/>
</ul>
</div>
......
<script>
import actionComponent from './action_component.vue';
import dropdownActionComponent from './dropdown_action_component.vue';
import jobNameComponent from './job_name_component.vue';
import tooltip from '../../../vue_shared/directives/tooltip';
/**
* Renders the badge for the pipeline graph and the job's dropdown.
*
* The following object should be provided as `job`:
*
* {
* "id": 4256,
* "name": "test",
* "status": {
* "icon": "icon_status_success",
* "text": "passed",
* "label": "passed",
* "group": "success",
* "details_path": "/root/ci-mock/builds/4256",
* "action": {
* "icon": "retry",
* "title": "Retry",
* "path": "/root/ci-mock/builds/4256/retry",
* "method": "post"
* }
* }
* }
*/
export default {
components: {
actionComponent,
dropdownActionComponent,
jobNameComponent,
import ActionComponent from './action_component.vue';
import DropdownActionComponent from './dropdown_action_component.vue';
import JobNameComponent from './job_name_component.vue';
import tooltip from '../../../vue_shared/directives/tooltip';
/**
* Renders the badge for the pipeline graph and the job's dropdown.
*
* The following object should be provided as `job`:
*
* {
* "id": 4256,
* "name": "test",
* "status": {
* "icon": "icon_status_success",
* "text": "passed",
* "label": "passed",
* "group": "success",
* "tooltip": "passed",
* "details_path": "/root/ci-mock/builds/4256",
* "action": {
* "icon": "retry",
* "title": "Retry",
* "path": "/root/ci-mock/builds/4256/retry",
* "method": "post"
* }
* }
* }
*/
export default {
components: {
ActionComponent,
DropdownActionComponent,
JobNameComponent,
},
directives: {
tooltip,
},
props: {
job: {
type: Object,
required: true,
},
directives: {
tooltip,
cssClassJobName: {
type: String,
required: false,
default: '',
},
props: {
job: {
type: Object,
required: true,
},
cssClassJobName: {
type: String,
required: false,
default: '',
},
isDropdown: {
type: Boolean,
required: false,
default: false,
},
isDropdown: {
type: Boolean,
required: false,
default: false,
},
actionDisabled: {
type: String,
required: false,
default: null,
},
},
computed: {
status() {
return this.job && this.job.status ? this.job.status : {};
},
tooltipText() {
const textBuilder = [];
if (this.job.name) {
textBuilder.push(this.job.name);
}
if (this.job.name && this.status.tooltip) {
textBuilder.push('-');
}
if (this.status.tooltip) {
textBuilder.push(`${this.job.status.tooltip}`);
}
return textBuilder.join(' ');
},
computed: {
status() {
return this.job && this.job.status ? this.job.status : {};
},
tooltipText() {
const textBuilder = [];
if (this.job.name) {
textBuilder.push(this.job.name);
}
if (this.job.name && this.status.label) {
textBuilder.push('-');
}
if (this.status.label) {
textBuilder.push(`${this.job.status.label}`);
}
return textBuilder.join(' ');
},
/**
* Verifies if the provided job has an action path
*
* @return {Boolean}
*/
hasAction() {
return this.job.status && this.job.status.action && this.job.status.action.path;
},
/**
* Verifies if the provided job has an action path
*
* @return {Boolean}
*/
hasAction() {
return this.job.status && this.job.status.action && this.job.status.action.path;
},
};
},
};
</script>
<template>
<div class="ci-job-component">
......@@ -100,6 +107,7 @@
:title="tooltipText"
:class="cssClassJobName"
data-container="body"
data-html="true"
class="js-pipeline-graph-job-link"
>
......@@ -115,6 +123,7 @@
class="js-job-component-tooltip"
:title="tooltipText"
:class="cssClassJobName"
data-html="true"
data-container="body"
>
......@@ -129,7 +138,7 @@
:tooltip-text="status.action.title"
:link="status.action.path"
:action-icon="status.action.icon"
:action-method="status.action.method"
:button-disabled="actionDisabled"
/>
<dropdown-action-component
......
<script>
import jobComponent from './job_component.vue';
import dropdownJobComponent from './dropdown_job_component.vue';
import JobComponent from './job_component.vue';
import DropdownJobComponent from './dropdown_job_component.vue';
export default {
components: {
jobComponent,
dropdownJobComponent,
export default {
components: {
JobComponent,
DropdownJobComponent,
},
props: {
title: {
type: String,
required: true,
},
props: {
title: {
type: String,
required: true,
},
jobs: {
type: Array,
required: true,
},
jobs: {
type: Array,
required: true,
},
isFirstColumn: {
type: Boolean,
required: false,
default: false,
},
isFirstColumn: {
type: Boolean,
required: false,
default: false,
},
stageConnectorClass: {
type: String,
required: false,
default: '',
},
stageConnectorClass: {
type: String,
required: false,
default: '',
},
actionDisabled: {
type: String,
required: false,
default: null,
},
},
methods: {
firstJob(list) {
return list[0];
},
methods: {
firstJob(list) {
return list[0];
},
jobId(job) {
return `ci-badge-${job.name}`;
},
jobId(job) {
return `ci-badge-${job.name}`;
},
buildConnnectorClass(index) {
return index === 0 && !this.isFirstColumn ? 'left-connector' : '';
},
buildConnnectorClass(index) {
return index === 0 && !this.isFirstColumn ? 'left-connector' : '';
},
};
},
};
</script>
<template>
<li
......@@ -69,6 +74,7 @@
v-if="job.size === 1"
:job="job"
css-class-job-name="build-content"
:action-disabled="actionDisabled"
/>
<dropdown-job-component
......
......@@ -25,13 +25,36 @@ export default () => {
data() {
return {
mediator,
actionDisabled: null,
};
},
created() {
eventHub.$on('graphAction', this.postAction);
},
beforeDestroy() {
eventHub.$off('graphAction', this.postAction);
},
methods: {
postAction(action) {
this.actionDisabled = action;
this.mediator.service.postAction(action)
.then(() => {
this.mediator.refreshPipeline();
this.actionDisabled = null;
})
.catch(() => {
this.actionDisabled = null;
Flash(__('An error occurred while making the request.'));
});
},
},
render(createElement) {
return createElement('pipeline-graph', {
props: {
isLoading: this.mediator.state.isLoading,
pipeline: this.mediator.store.state.pipeline,
actionDisabled: this.actionDisabled,
},
});
},
......
......@@ -52,8 +52,11 @@ export default class pipelinesMediator {
}
refreshPipeline() {
this.service.getPipeline()
this.poll.stop();
return this.service.getPipeline()
.then(response => this.successCallback(response))
.catch(() => this.errorCallback());
.catch(() => this.errorCallback())
.finally(() => this.poll.restart());
}
}
<script>
import _ from 'underscore';
import axios from '~/lib/utils/axios_utils';
import GlModal from '~/vue_shared/components/gl_modal.vue';
import { s__, sprintf } from '~/locale';
import Flash from '~/flash';
export default {
components: {
GlModal,
},
props: {
actionUrl: {
type: String,
required: true,
},
rootUrl: {
type: String,
required: true,
},
initialUsername: {
type: String,
required: true,
},
},
data() {
return {
isRequestPending: false,
username: this.initialUsername,
newUsername: this.initialUsername,
};
},
computed: {
path() {
return sprintf(s__('Profiles|Current path: %{path}'), {
path: `${this.rootUrl}${this.username}`,
});
},
modalText() {
return sprintf(
s__(`Profiles|
You are going to change the username %{currentUsernameBold} to %{newUsernameBold}.
Profile and projects will be redirected to the %{newUsername} namespace but this redirect will expire once the %{currentUsername} namespace is registered by another user or group.
Please update your Git repository remotes as soon as possible.`),
{
currentUsernameBold: `<strong>${_.escape(this.username)}</strong>`,
newUsernameBold: `<strong>${_.escape(this.newUsername)}</strong>`,
currentUsername: _.escape(this.username),
newUsername: _.escape(this.newUsername),
},
false,
);
},
},
methods: {
onConfirm() {
this.isRequestPending = true;
const username = this.newUsername;
const putData = {
user: {
username,
},
};
return axios
.put(this.actionUrl, putData)
.then(result => {
Flash(result.data.message, 'notice');
this.username = username;
this.isRequestPending = false;
})
.catch(error => {
Flash(error.response.data.message);
this.isRequestPending = false;
throw error;
});
},
},
modalId: 'username-change-confirmation-modal',
inputId: 'username-change-input',
buttonText: s__('Profiles|Update username'),
};
</script>
<template>
<div>
<div class="form-group">
<label :for="$options.inputId">{{ s__('Profiles|Path') }}</label>
<div class="input-group">
<div class="input-group-addon">{{ rootUrl }}</div>
<input
:id="$options.inputId"
class="form-control"
required="required"
v-model="newUsername"
:disabled="isRequestPending"
/>
</div>
<p class="help-block">
{{ path }}
</p>
</div>
<button
:data-target="`#${$options.modalId}`"
class="btn btn-warning"
type="button"
data-toggle="modal"
:disabled="isRequestPending || newUsername === username"
>
{{ $options.buttonText }}
</button>
<gl-modal
:id="$options.modalId"
:header-title-text="s__('Profiles|Change username') + '?'"
footer-primary-button-variant="warning"
:footer-primary-button-text="$options.buttonText"
@submit="onConfirm"
>
<span v-html="modalText"></span>
</gl-modal>
</div>
</template>
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import UpdateUsername from './components/update_username.vue';
import deleteAccountModal from './components/delete_account_modal.vue';
export default () => {
Vue.use(Translate);
const updateUsernameElement = document.getElementById('update-username');
// eslint-disable-next-line no-new
new Vue({
el: updateUsernameElement,
components: {
UpdateUsername,
},
render(createElement) {
return createElement('update-username', {
props: { ...updateUsernameElement.dataset },
});
},
});
const deleteAccountButton = document.getElementById('delete-account-button');
const deleteAccountModalEl = document.getElementById('delete-account-modal');
// eslint-disable-next-line no-new
......
......@@ -233,21 +233,21 @@ export default class SearchAutocomplete {
const issueItems = [
{
text: 'Issues assigned to me',
url: `${issuesPath}/?assignee_username=${userName}`,
url: `${issuesPath}/?assignee_id=${userId}`,
},
{
text: "Issues I've created",
url: `${issuesPath}/?author_username=${userName}`,
url: `${issuesPath}/?author_id=${userId}`,
},
];
const mergeRequestItems = [
{
text: 'Merge requests assigned to me',
url: `${mrPath}/?assignee_username=${userName}`,
url: `${mrPath}/?assignee_id=${userId}`,
},
{
text: "Merge requests I've created",
url: `${mrPath}/?author_username=${userName}`,
url: `${mrPath}/?author_id=${userId}`,
},
];
......
<script>
import { viewerInformationForPath } from './lib/viewer_utils';
import MarkdownViewer from './viewers/markdown_viewer.vue';
import ImageViewer from './viewers/image_viewer.vue';
import DownloadViewer from './viewers/download_viewer.vue';
export default {
props: {
content: {
type: String,
default: '',
},
path: {
type: String,
required: true,
},
fileSize: {
type: Number,
required: false,
default: 0,
},
projectPath: {
type: String,
required: false,
default: '',
},
},
computed: {
viewer() {
if (!this.path) return null;
const previewInfo = viewerInformationForPath(this.path);
if (!previewInfo) return DownloadViewer;
switch (previewInfo.id) {
case 'markdown':
return MarkdownViewer;
case 'image':
return ImageViewer;
default:
return DownloadViewer;
}
},
},
};
</script>
<template>
<div class="preview-container">
<component
:is="viewer"
:path="path"
:file-size="fileSize"
:project-path="projectPath"
:content="content"
/>
</div>
</template>
const viewers = {
image: {
id: 'image',
},
markdown: {
id: 'markdown',
previewTitle: 'Preview Markdown',
},
};
const fileNameViewers = {};
const fileExtensionViewers = {
jpg: 'image',
jpeg: 'image',
gif: 'image',
png: 'image',
bmp: 'image',
ico: 'image',
md: 'markdown',
markdown: 'markdown',
};
export function viewerInformationForPath(path) {
if (!path) return null;
const name = path.split('/').pop();
const viewerName =
fileNameViewers[name] || fileExtensionViewers[name ? name.split('.').pop() : ''] || '';
return viewers[viewerName];
}
export default viewers;
<script>
import Icon from '../../icon.vue';
import { numberToHumanSize } from '../../../../lib/utils/number_utils';
export default {
components: {
Icon,
},
props: {
path: {
type: String,
required: true,
},
fileSize: {
type: Number,
required: false,
default: 0,
},
},
computed: {
fileSizeReadable() {
return numberToHumanSize(this.fileSize);
},
fileName() {
return this.path.split('/').pop();
},
},
};
</script>
<template>
<div class="file-container">
<div class="file-content">
<p class="prepend-top-10 file-info">
{{ fileName }} ({{ fileSizeReadable }})
</p>
<a
:href="path"
class="btn btn-default"
rel="nofollow"
download
target="_blank">
<icon
name="download"
css-classes="pull-left append-right-8"
:size="16"
/>
{{ __('Download') }}
</a>
</div>
</div>
</template>
<script>
import { numberToHumanSize } from '../../../../lib/utils/number_utils';
export default {
props: {
path: {
type: String,
required: true,
},
fileSize: {
type: Number,
required: false,
default: 0,
},
},
data() {
return {
width: 0,
height: 0,
isZoomable: false,
isZoomed: false,
};
},
computed: {
fileSizeReadable() {
return numberToHumanSize(this.fileSize);
},
},
methods: {
onImgLoad() {
const contentImg = this.$refs.contentImg;
this.isZoomable =
contentImg.naturalWidth > contentImg.width || contentImg.naturalHeight > contentImg.height;
this.width = contentImg.naturalWidth;
this.height = contentImg.naturalHeight;
},
onImgClick() {
if (this.isZoomable) this.isZoomed = !this.isZoomed;
},
},
};
</script>
<template>
<div class="file-container">
<div class="file-content image_file">
<img
ref="contentImg"
:class="{ 'isZoomable': isZoomable, 'isZoomed': isZoomed }"
:src="path"
:alt="path"
@load="onImgLoad"
@click="onImgClick"/>
<p class="file-info prepend-top-10">
<template v-if="fileSize>0">
{{ fileSizeReadable }}
</template>
<template v-if="fileSize>0 && width && height">
-
</template>
<template v-if="width && height">
{{ width }} x {{ height }}
</template>
</p>
</div>
</div>
</template>
<script>
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import $ from 'jquery';
import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
const CancelToken = axios.CancelToken;
let axiosSource;
export default {
components: {
SkeletonLoadingContainer,
},
props: {
content: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
},
data() {
return {
previewContent: null,
isLoading: false,
};
},
watch: {
content() {
this.previewContent = null;
},
},
created() {
axiosSource = CancelToken.source();
this.fetchMarkdownPreview();
},
updated() {
this.fetchMarkdownPreview();
},
destroyed() {
if (this.isLoading) axiosSource.cancel('Cancelling Preview');
},
methods: {
fetchMarkdownPreview() {
if (this.content && this.previewContent === null) {
this.isLoading = true;
const postBody = {
text: this.content,
};
const postOptions = {
cancelToken: axiosSource.token,
};
axios
.post(
`${gon.relative_url_root}/${this.projectPath}/preview_markdown`,
postBody,
postOptions,
)
.then(({ data }) => {
this.previewContent = data.body;
this.isLoading = false;
this.$nextTick(() => {
$(this.$refs['markdown-preview']).renderGFM();
});
})
.catch(() => {
this.previewContent = __('An error occurred while fetching markdown preview');
this.isLoading = false;
});
}
},
},
};
</script>
<template>
<div
ref="markdown-preview"
class="md md-previewer">
<skeleton-loading-container v-if="isLoading" />
<div
v-else
v-html="previewContent">
</div>
</div>
</template>
<script>
const buttonVariants = [
'danger',
'primary',
'success',
'warning',
];
const buttonVariants = ['danger', 'primary', 'success', 'warning'];
export default {
name: 'GlModal',
export default {
name: 'GlModal',
props: {
id: {
type: String,
required: false,
default: null,
},
headerTitleText: {
type: String,
required: false,
default: '',
},
footerPrimaryButtonVariant: {
type: String,
required: false,
default: 'primary',
validator: value => buttonVariants.indexOf(value) !== -1,
},
footerPrimaryButtonText: {
type: String,
required: false,
default: '',
},
props: {
id: {
type: String,
required: false,
default: null,
},
headerTitleText: {
type: String,
required: false,
default: '',
},
footerPrimaryButtonVariant: {
type: String,
required: false,
default: 'primary',
validator: value => buttonVariants.includes(value),
},
footerPrimaryButtonText: {
type: String,
required: false,
default: '',
},
},
methods: {
emitCancel(event) {
this.$emit('cancel', event);
},
emitSubmit(event) {
this.$emit('submit', event);
},
methods: {
emitCancel(event) {
this.$emit('cancel', event);
},
emitSubmit(event) {
this.$emit('submit', event);
},
};
},
};
</script>
<template>
......@@ -60,7 +55,7 @@
<slot name="header">
<button
type="button"
class="close"
class="close js-modal-close-action"
data-dismiss="modal"
:aria-label="s__('Modal|Close')"
@click="emitCancel($event)"
......@@ -83,7 +78,7 @@
<slot name="footer">
<button
type="button"
class="btn"
class="btn js-modal-cancel-action"
data-dismiss="modal"
@click="emitCancel($event)"
>
......@@ -91,7 +86,7 @@
</button>
<button
type="button"
class="btn"
class="btn js-modal-primary-action"
:class="`btn-${footerPrimaryButtonVariant}`"
data-dismiss="modal"
@click="emitSubmit($event)"
......
......@@ -20,7 +20,7 @@
width: 100%;
}
$image-widths: 80 250 306 394 430;
$image-widths: 80 130 250 306 394 430;
@each $width in $image-widths {
&.svg-#{$width} {
img,
......
......@@ -24,6 +24,10 @@
color: $list-text-disabled-color;
}
&:not(.ui-sort-disabled):hover {
background: $row-hover;
}
&.unstyled {
&:hover {
background: none;
......@@ -34,14 +38,15 @@
background-color: $list-warning-row-bg;
border-color: $list-warning-row-border;
color: $list-warning-row-color;
}
&.smoke { background-color: $gray-light; }
&:hover {
background: $list-warning-row-bg;
}
&:not(.ui-sort-disabled):hover {
background: $row-hover;
}
&.smoke { background-color: $gray-light; }
&:last-child {
border-bottom: 0;
......
......@@ -289,6 +289,11 @@ body {
&:last-child {
margin-bottom: 0;
}
&.with-button {
line-height: 34px;
}
}
.page-title-empty {
......
......@@ -767,3 +767,8 @@ $border-color-settings: #e1e1e1;
Modals
*/
$modal-body-height: 134px;
/*
Prometheus
*/
$prometheus-table-row-highlight-color: $theme-gray-100;
......@@ -391,7 +391,7 @@
}
&:hover {
background-color: $row-hover;
background-color: $dropdown-item-hover-bg;
}
.icon-retry {
......
......@@ -107,7 +107,6 @@
}
}
.commits-compare-switch {
float: left;
margin-right: 9px;
......@@ -179,7 +178,7 @@
.commit-detail {
display: flex;
justify-content: space-between;
align-items: flex-start;
align-items: center;
flex-grow: 1;
.merge-request-branches & {
......@@ -200,37 +199,63 @@
}
.ci-status-link {
display: inline-block;
position: relative;
top: 2px;
display: inline-flex;
}
.btn-clipboard,
.btn-transparent {
padding-left: 0;
padding-right: 0;
> .ci-status-link,
> .btn,
> .commit-sha-group {
margin-left: $gl-padding-8;
}
}
.commit-sha-group {
display: inline-flex;
.label,
.btn {
&:not(:first-child) {
margin-left: $gl-padding;
}
padding: $gl-vert-padding $gl-btn-padding;
border: 1px $border-color solid;
font-size: $gl-font-size;
line-height: $line-height-base;
border-radius: 0;
display: flex;
align-items: center;
}
.label-monospace {
@extend .monospace;
user-select: text;
color: $gl-text-color;
background-color: $gray-light;
}
.commit-sha {
font-size: 14px;
font-weight: $gl-font-weight-bold;
.btn svg {
top: auto;
fill: $gl-text-color-secondary;
}
.ci-status-icon {
position: relative;
top: 2px;
.fa-clipboard {
color: $gl-text-color-secondary;
}
:first-child {
border-bottom-left-radius: $border-radius-default;
border-top-left-radius: $border-radius-default;
}
:not(:first-child) {
border-left: 0;
}
:last-child {
border-bottom-right-radius: $border-radius-default;
border-top-right-radius: $border-radius-default;
}
}
.commit,
.generic_commit_status {
a,
button {
color: $gl-text-color;
......@@ -303,10 +328,8 @@
}
}
.gpg-status-box {
padding: 2px 10px;
margin-right: $gl-padding;
&:empty {
display: none;
......
......@@ -813,6 +813,7 @@
}
.discussion-notes {
padding: 0 $gl-padding $gl-padding;
min-height: 35px;
&:first-child {
......
......@@ -273,21 +273,6 @@
line-height: 1.2;
}
table {
border-collapse: collapse;
padding: 0;
margin: 0;
}
td {
vertical-align: middle;
+ td {
padding-left: 5px;
vertical-align: top;
}
}
.deploy-meta-content {
border-bottom: 1px solid $white-dark;
......@@ -323,6 +308,26 @@
}
}
.prometheus-table {
border-collapse: collapse;
padding: 0;
margin: 0;
td {
vertical-align: middle;
+ td {
padding-left: 5px;
vertical-align: top;
}
}
.legend-metric-title {
font-size: 12px;
vertical-align: middle;
}
}
.prometheus-svg-container {
position: relative;
height: 0;
......@@ -330,8 +335,7 @@
padding: 0;
padding-bottom: 100%;
.text-metric-usage,
.legend-metric-title {
.text-metric-usage {
fill: $black;
font-weight: $gl-font-weight-normal;
font-size: 12px;
......@@ -374,10 +378,6 @@
}
}
.text-metric-title {
font-size: 12px;
}
.y-label-text,
.x-label-text {
fill: $gray-darkest;
......@@ -414,3 +414,7 @@
}
}
}
.prometheus-table-row-highlight {
background-color: $prometheus-table-row-highlight-color;
}
......@@ -173,11 +173,7 @@
}
.discussion-form {
background-color: $white-light;
}
.discussion-form-container {
padding: $gl-padding-top $gl-padding $gl-padding;
padding-top: $gl-padding-top;
}
.discussion-notes .disabled-comment {
......@@ -237,12 +233,7 @@
.discussion-body,
.diff-file {
.discussion-reply-holder {
background-color: $white-light;
padding: 10px 16px;
&.is-replying {
padding-bottom: $gl-padding;
}
padding-top: $gl-padding;
}
}
......
......@@ -47,7 +47,7 @@ ul.notes {
}
.timeline-entry-inner {
padding: $gl-padding $gl-btn-padding;
padding: $gl-padding 0;
border-bottom: 1px solid $white-normal;
}
......@@ -94,12 +94,6 @@ ul.notes {
}
}
&.note-discussion {
.timeline-entry-inner {
padding: $gl-padding 10px;
}
}
.editing-spinner {
display: none;
}
......@@ -352,6 +346,8 @@ ul.notes {
}
.discussion-notes {
background-color: $white-light;
&:not(:first-child) {
border-top: 1px solid $white-normal;
margin-top: 20px;
......@@ -363,10 +359,6 @@ ul.notes {
}
}
.notes {
background-color: $white-light;
}
a code {
top: 0;
margin-right: 0;
......@@ -647,8 +639,6 @@ ul.notes {
border-bottom: 1px solid $white-normal;
.timeline-entry-inner {
padding-left: $gl-padding;
padding-right: $gl-padding;
border-bottom: 0;
}
}
......
.pages-domain-list {
&-item {
position: relative;
display: flex;
align-items: center;
.domain-status {
display: inline-flex;
left: $gl-padding;
position: absolute;
}
.domain-name {
flex-grow: 1;
}
}
&.has-verification-status > li {
padding-left: 3 * $gl-padding;
}
}
.status-badge {
display: inline-flex;
margin-bottom: $gl-padding-8;
// Most of the following settings "stolen" from btn-sm
// Border radius is overwritten for both
.label,
.btn {
padding: $gl-padding-4 $gl-padding-8;
font-size: $gl-font-size;
line-height: $gl-btn-line-height;
border-radius: 0;
display: flex;
align-items: center;
}
.btn svg {
top: auto;
}
:first-child {
border-bottom-left-radius: $border-radius-default;
border-top-left-radius: $border-radius-default;
}
:not(:first-child) {
border-left: 0;
}
:last-child {
border-bottom-right-radius: $border-radius-default;
border-top-right-radius: $border-radius-default;
}
}
......@@ -495,17 +495,17 @@
svg {
fill: $gl-text-color-secondary;
position: relative;
left: 5px;
top: 2px;
width: 18px;
height: 18px;
left: 1px;
top: -1px;
width: 16px;
height: 16px;
}
&.play {
svg {
width: #{$ci-action-icon-size - 8};
height: #{$ci-action-icon-size - 8};
left: 8px;
width: 16px;
height: 16px;
left: 3px;
}
}
}
......
......@@ -210,13 +210,8 @@
}
.created-personal-access-token-container {
#created-personal-access-token {
width: 90%;
display: inline;
}
.btn-clipboard {
margin-left: 5px;
border: 1px solid $border-color;
}
}
......
......@@ -312,14 +312,73 @@
height: 100%;
}
.multi-file-editor-btn-group {
padding: $gl-bar-padding $gl-padding;
border-top: 1px solid $white-dark;
.preview-container {
height: 100%;
overflow: auto;
.file-container {
background-color: $gray-darker;
display: flex;
height: 100%;
align-items: center;
justify-content: center;
text-align: center;
.file-content {
padding: $gl-padding;
max-width: 100%;
max-height: 100%;
img {
max-width: 90%;
max-height: 90%;
}
.isZoomable {
cursor: pointer;
cursor: zoom-in;
&.isZoomed {
cursor: pointer;
cursor: zoom-out;
max-width: none;
max-height: none;
margin-right: $gl-padding;
}
}
}
.file-info {
font-size: $label-font-size;
color: $diff-image-info-color;
}
}
.md-previewer {
padding: $gl-padding;
}
}
.ide-mode-tabs {
border-bottom: 1px solid $white-dark;
background: $white-light;
.nav-links {
border-bottom: 0;
li a {
padding: $gl-padding-8 $gl-padding;
line-height: $gl-btn-line-height;
}
}
}
.ide-btn-group {
padding: $gl-padding-4 $gl-vert-padding;
}
.ide-status-bar {
border-top: 1px solid $white-dark;
padding: $gl-bar-padding $gl-padding;
background: $white-light;
display: flex;
......
......@@ -96,7 +96,8 @@ module Boards
resource.as_json(
only: [:id, :iid, :project_id, :title, :confidential, :due_date, :relative_position],
labels: true,
sidebar_endpoints: true,
issue_endpoints: true,
include_full_project_path: board.group_board?,
include: {
project: { only: [:id, :path] },
assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
......
......@@ -10,7 +10,7 @@ module AuthenticatesWithTwoFactor
# This action comes from DeviseController, but because we call `sign_in`
# manually, not skipping this action would cause a "You are already signed
# in." error message to be shown upon successful login.
skip_before_action :require_no_authentication, only: [:create]
skip_before_action :require_no_authentication, only: [:create], raise: false
end
# Store the user's ID in the session for later retrieval and render the
......
......@@ -2,9 +2,17 @@ class DashboardController < Dashboard::ApplicationController
include IssuesAction
include MergeRequestsAction
FILTER_PARAMS = [
:author_id,
:assignee_id,
:milestone_title,
:label_name
].freeze
before_action :event_filter, only: :activity
before_action :projects, only: [:issues, :merge_requests]
before_action :set_show_full_reference, only: [:issues, :merge_requests]
before_action :check_filters_presence!, only: [:issues, :merge_requests]
respond_to :html
......@@ -39,4 +47,15 @@ class DashboardController < Dashboard::ApplicationController
def set_show_full_reference
@show_full_reference = true
end
def check_filters_presence!
@no_filters_set = FILTER_PARAMS.none? { |k| params.key?(k) }
return unless @no_filters_set
respond_to do |format|
format.html
format.atom { head :bad_request }
end
end
end
......@@ -12,7 +12,7 @@ class Groups::MilestonesController < Groups::ApplicationController
@milestones = Kaminari.paginate_array(milestones).page(params[:page])
end
format.json do
render json: milestones.map { |m| m.for_display.slice(:title, :name) }
render json: milestones.map { |m| m.for_display.slice(:id, :title, :name) }
end
end
end
......
......@@ -53,13 +53,19 @@ class ProfilesController < Profiles::ApplicationController
def update_username
result = Users::UpdateService.new(current_user, user: @user, username: username_param).execute
options = if result[:status] == :success
{ notice: "Username successfully changed" }
else
{ alert: "Username change failed - #{result[:message]}" }
end
respond_to do |format|
if result[:status] == :success
message = s_("Profiles|Username successfully changed")
redirect_back_or_default(default: { action: 'show' }, options: options)
format.html { redirect_back_or_default(default: { action: 'show' }, options: { notice: message }) }
format.json { render json: { message: message }, status: :ok }
else
message = s_("Profiles|Username change failed - %{message}") % { message: result[:message] }
format.html { redirect_back_or_default(default: { action: 'show' }, options: { alert: message }) }
format.json { render json: { message: message }, status: :unprocessable_entity }
end
end
end
private
......
......@@ -4,8 +4,8 @@ class Projects::DiscussionsController < Projects::ApplicationController
before_action :check_merge_requests_available!
before_action :merge_request
before_action :discussion
before_action :authorize_resolve_discussion!
before_action :discussion, only: [:resolve, :unresolve]
before_action :authorize_resolve_discussion!, only: [:resolve, :unresolve]
def resolve
Discussions::ResolveService.new(project, current_user, merge_request: merge_request).execute(discussion)
......
......@@ -7,6 +7,7 @@ class Projects::GitHttpClientController < Projects::ApplicationController
attr_reader :authentication_result, :redirected_path
delegate :actor, :authentication_abilities, to: :authentication_result, allow_nil: true
delegate :type, to: :authentication_result, allow_nil: true, prefix: :auth_result
alias_method :user, :actor
alias_method :authenticated_user, :actor
......
......@@ -64,7 +64,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController
@access ||= access_klass.new(access_actor, project,
'http', authentication_abilities: authentication_abilities,
namespace_path: params[:namespace_id], project_path: project_path,
redirected_path: redirected_path)
redirected_path: redirected_path, auth_result_type: auth_result_type)
end
def access_actor
......
......@@ -2,7 +2,6 @@ class Projects::JobsController < Projects::ApplicationController
include SendFileUpload
before_action :build, except: [:index, :cancel_all]
before_action :authorize_read_build!,
only: [:index, :show, :status, :raw, :trace]
before_action :authorize_update_build!,
......@@ -45,8 +44,11 @@ class Projects::JobsController < Projects::ApplicationController
end
def show
@builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC')
@builds = @builds.where("id not in (?)", @build.id)
@builds = @project.pipelines
.find_by_sha(@build.sha)
.builds
.order('id DESC')
.present(current_user: current_user)
@pipeline = @build.pipeline
respond_to do |format|
......@@ -128,7 +130,7 @@ class Projects::JobsController < Projects::ApplicationController
if stream.file?
send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline'
else
render_404
send_data stream.raw, type: 'text/plain; charset=utf-8', disposition: 'inline', filename: 'job.log'
end
end
end
......
......@@ -31,7 +31,9 @@ class Projects::LfsStorageController < Projects::GitHttpClientController
render plain: 'Unprocessable entity', status: 422
end
rescue ActiveRecord::RecordInvalid
render_400
render_lfs_forbidden
rescue UploadedFile::InvalidPathError
render_lfs_forbidden
rescue ObjectStorage::RemoteStoreError
render_lfs_forbidden
end
......@@ -66,10 +68,11 @@ class Projects::LfsStorageController < Projects::GitHttpClientController
end
def create_file!(oid, size)
LfsObject.new(oid: oid, size: size).tap do |object|
object.file.store_workhorse_file!(params, :file)
object.save!
end
uploaded_file = UploadedFile.from_params(
params, :file, LfsObjectUploader.workhorse_local_upload_path)
return unless uploaded_file
LfsObject.create!(oid: oid, size: size, file: uploaded_file)
end
def link_to_project!(object)
......
......@@ -4,41 +4,4 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController
def show
redirect_to project_settings_ci_cd_path(@project, params: params)
end
def update
Projects::UpdateService.new(project, current_user, update_params).tap do |service|
if service.execute
flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated."
run_autodevops_pipeline(service)
redirect_to project_settings_ci_cd_path(@project)
else
render 'show'
end
end
end
private
def run_autodevops_pipeline(service)
return unless service.run_auto_devops_pipeline?
if @project.empty_repo?
flash[:warning] = "This repository is currently empty. A new Auto DevOps pipeline will be created after a new file has been pushed to a branch."
return
end
CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false)
flash[:success] = "A new Auto DevOps pipeline has been created, go to <a href=\"#{project_pipelines_path(@project)}\">Pipelines page</a> for details".html_safe
end
def update_params
params.require(:project).permit(
:runners_token, :builds_enabled, :build_allow_git_fetch,
:build_timeout_in_minutes, :build_coverage_regex, :public_builds,
:auto_cancel_pending_pipelines, :ci_config_path,
auto_devops_attributes: [:id, :domain, :enabled]
)
end
end
......@@ -25,7 +25,7 @@ class Projects::RefsController < Projects::ApplicationController
when "graphs_commits"
commits_project_graph_path(@project, @id)
when "badges"
project_pipelines_settings_path(@project, ref: @id)
project_settings_ci_cd_path(@project, ref: @id)
else
project_commits_path(@project, @id)
end
......
class Projects::RepositoriesController < Projects::ApplicationController
include ExtractsPath
# Authorize
before_action :require_non_empty_project, except: :create
before_action :assign_archive_vars, only: :archive
before_action :authorize_download_code!
before_action :authorize_admin_project!, only: :create
......@@ -11,9 +14,21 @@ class Projects::RepositoriesController < Projects::ApplicationController
end
def archive
send_git_archive @repository, ref: params[:ref], format: params[:format]
append_sha = params[:append_sha]
shortname = "#{@project.path}-#{@ref.tr('/', '-')}"
append_sha = false if @filename == shortname
send_git_archive @repository, ref: @ref, format: params[:format], append_sha: append_sha
rescue => ex
logger.error("#{self.class.name}: #{ex}")
return git_not_found!
end
def assign_archive_vars
@id = params[:id]
@ref, @filename = extract_ref(@id)
rescue InvalidPathError
render_404
end
end
......@@ -2,13 +2,24 @@ module Projects
module Settings
class CiCdController < Projects::ApplicationController
before_action :authorize_admin_pipeline!
before_action :define_variables
def show
define_runners_variables
define_secret_variables
define_triggers_variables
define_badges_variables
define_auto_devops_variables
end
def update
Projects::UpdateService.new(project, current_user, update_params).tap do |service|
result = service.execute
if result[:status] == :success
flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated."
run_autodevops_pipeline(service)
redirect_to project_settings_ci_cd_path(@project)
else
render 'show'
end
end
end
def reset_cache
......@@ -25,6 +36,35 @@ module Projects
private
def update_params
params.require(:project).permit(
:runners_token, :builds_enabled, :build_allow_git_fetch,
:build_timeout_human_readable, :build_coverage_regex, :public_builds,
:auto_cancel_pending_pipelines, :ci_config_path,
auto_devops_attributes: [:id, :domain, :enabled]
)
end
def run_autodevops_pipeline(service)
return unless service.run_auto_devops_pipeline?
if @project.empty_repo?
flash[:warning] = "This repository is currently empty. A new Auto DevOps pipeline will be created after a new file has been pushed to a branch."
return
end
CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false)
flash[:success] = "A new Auto DevOps pipeline has been created, go to <a href=\"#{project_pipelines_path(@project)}\">Pipelines page</a> for details".html_safe
end
def define_variables
define_runners_variables
define_secret_variables
define_triggers_variables
define_badges_variables
define_auto_devops_variables
end
def define_runners_variables
@project_runners = @project.runners.ordered
@assignable_runners = current_user.ci_authorized_runners
......
......@@ -324,7 +324,7 @@ class ProjectsController < Projects::ApplicationController
:avatar,
:build_allow_git_fetch,
:build_coverage_regex,
:build_timeout_in_minutes,
:build_timeout_human_readable,
:resolve_outdated_diff_discussions,
:container_registry_enabled,
:default_branch,
......
......@@ -228,9 +228,7 @@ module ApplicationHelper
scope: params[:scope],
milestone_title: params[:milestone_title],
assignee_id: params[:assignee_id],
assignee_username: params[:assignee_username],
author_id: params[:author_id],
author_username: params[:author_username],
search: params[:search],
label_name: params[:label_name]
}
......
......@@ -93,25 +93,18 @@ module CommitsHelper
return unless current_controller?(:commits)
if @path.blank?
return link_to(
_("Browse Files"),
project_tree_path(project, commit),
class: "btn btn-default"
)
url = project_tree_path(project, commit)
tooltip = _("Browse Files")
elsif @repo.blob_at(commit.id, @path)
return link_to(
_("Browse File"),
project_blob_path(project,
tree_join(commit.id, @path)),
class: "btn btn-default"
)
url = project_blob_path(project, tree_join(commit.id, @path))
tooltip = _("Browse File")
elsif @path.present?
return link_to(
_("Browse Directory"),
project_tree_path(project,
tree_join(commit.id, @path)),
class: "btn btn-default"
)
url = project_tree_path(project, tree_join(commit.id, @path))
tooltip = _("Browse Directory")
end
link_to url, class: "btn btn-default has-tooltip", title: tooltip, data: { container: "body" } do
sprite_icon('folder-open')
end
end
......
......@@ -159,16 +159,18 @@ module IssuablesHelper
label_names.join(', ')
end
def issuables_state_counter_text(issuable_type, state)
def issuables_state_counter_text(issuable_type, state, display_count)
titles = {
opened: "Open"
}
state_title = titles[state] || state.to_s.humanize
count = issuables_count_for_state(issuable_type, state)
html = content_tag(:span, state_title)
html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge')
if display_count
count = issuables_count_for_state(issuable_type, state)
html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge')
end
html.html_safe
end
......@@ -191,24 +193,10 @@ module IssuablesHelper
end
end
def issuable_filter_params
[
:search,
:author_id,
:assignee_id,
:milestone_title,
:label_name
]
end
def issuable_reference(issuable)
@show_full_reference ? issuable.to_reference(full: true) : issuable.to_reference(@group || @project)
end
def issuable_filter_present?
issuable_filter_params.any? { |k| params.key?(k) }
end
def issuable_initial_data(issuable)
data = {
endpoint: issuable_path(issuable),
......
module ServicesHelper
def service_event_description(event)
case event
when "push", "push_events"
"Event will be triggered by a push to the repository"
when "tag_push", "tag_push_events"
"Event will be triggered when a new tag is pushed to the repository"
when "note", "note_events"
"Event will be triggered when someone adds a comment"
when "confidential_note", "confidential_note_events"
"Event will be triggered when someone adds a comment on a confidential issue"
when "issue", "issue_events"
"Event will be triggered when an issue is created/updated/closed"
when "confidential_issue", "confidential_issues_events"
"Event will be triggered when a confidential issue is created/updated/closed"
when "merge_request", "merge_request_events"
"Event will be triggered when a merge request is created/updated/merged"
when "pipeline", "pipeline_events"
"Event will be triggered when a pipeline status changes"
when "wiki_page", "wiki_page_events"
"Event will be triggered when a wiki page is created/updated"
when "commit", "commit_events"
"Event will be triggered when a commit is created/updated"
end
end
def service_event_field_name(event)
event = event.pluralize if %w[merge_request issue confidential_issue].include?(event)
"#{event}_events"
......
......@@ -24,8 +24,8 @@ module WorkhorseHelper
end
# Archive a Git repository and send it through Workhorse
def send_git_archive(repository, ref:, format:)
headers.store(*Gitlab::Workhorse.send_git_archive(repository, ref: ref, format: format))
def send_git_archive(repository, **kwargs)
headers.store(*Gitlab::Workhorse.send_git_archive(repository, **kwargs))
head :ok
end
......
......@@ -16,7 +16,7 @@ class Appearance < ActiveRecord::Base
has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
CACHE_KEY = 'current_appearance'.freeze
CACHE_KEY = "current_appearance:#{Gitlab::VERSION}".freeze
after_commit :flush_redis_cache
......
......@@ -7,6 +7,7 @@ module Ci
belongs_to :project
belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id
before_save :update_file_store
before_save :set_size, if: :file_changed?
scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) }
......@@ -21,6 +22,10 @@ module Ci
trace: 3
}
def update_file_store
self.file_store = file.object_store
end
def self.artifacts_size_for(project)
self.where(project: project).sum(:size)
end
......
......@@ -30,6 +30,8 @@ class Commit
MIN_SHA_LENGTH = Gitlab::Git::Commit::MIN_SHA_LENGTH
COMMIT_SHA_PATTERN = /\h{#{MIN_SHA_LENGTH},40}/.freeze
# Used by GFM to match and present link extensions on node texts and hrefs.
LINK_EXTENSION_PATTERN = /(patch)/.freeze
def banzai_render_context(field)
pipeline = field == :description ? :commit_description : :single_line
......@@ -143,7 +145,8 @@ class Commit
end
def self.link_reference_pattern
@link_reference_pattern ||= super("commit", /(?<commit>#{COMMIT_SHA_PATTERN})/)
@link_reference_pattern ||=
super("commit", /(?<commit>#{COMMIT_SHA_PATTERN})?(\.(?<extension>#{LINK_EXTENSION_PATTERN}))?/)
end
def to_reference(from = nil, full: false)
......
......@@ -8,14 +8,14 @@ module ChronicDurationAttribute
end
end
def chronic_duration_attr_writer(virtual_attribute, source_attribute)
def chronic_duration_attr_writer(virtual_attribute, source_attribute, parameters = {})
chronic_duration_attr_reader(virtual_attribute, source_attribute)
define_method("#{virtual_attribute}=") do |value|
chronic_duration_attributes[virtual_attribute] = value.presence || ''
chronic_duration_attributes[virtual_attribute] = value.presence || parameters[:default].presence.to_s
begin
new_value = ChronicDuration.parse(value).to_i if value.present?
new_value = value.present? ? ChronicDuration.parse(value).to_i : parameters[:default].presence
assign_attributes(source_attribute => new_value)
rescue ChronicDuration::DurationParseError
# ignore error as it will be caught by validation
......
module Presentable
extend ActiveSupport::Concern
class_methods do
def present(attributes)
all.map { |klass_object| klass_object.present(attributes) }
end
end
def present(**attributes)
Gitlab::View::Presenter::Factory
.new(self, attributes)
......
......@@ -224,7 +224,7 @@ class Environment < ActiveRecord::Base
end
def deployment_platform
project.deployment_platform(environment: self)
project.deployment_platform(environment: self.name)
end
private
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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