Commit 26ffa151 authored by GitLab Bot's avatar GitLab Bot

Merge remote-tracking branch 'upstream/master' into ce-to-ee-2018-06-13

# Conflicts:
#	app/assets/javascripts/boards/components/board_sidebar.js
#	app/assets/javascripts/boards/components/issue_card_inner.js
#	app/assets/javascripts/ide/components/repo_commit_section.vue
#	app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js
#	app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js
#	app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
#	app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
#	app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
#	app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue
#	app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
#	app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
#	doc/user/project/issue_board.md
#	doc/user/project/quick_actions.md
#	spec/services/quick_actions/interpret_service_spec.rb

[ci skip]
parents e74d9872 7b7ba297
......@@ -302,7 +302,7 @@ For guidance on UX implementation at GitLab, please refer to our [Design System]
The UX team uses labels to manage their workflow.
The ~"UX" label on an issue is a signal to the UX team that it will need UX attention.
To better understand the priority by which UX tackles issues, see the [UX section](https://about.gitlab.com/handbook/ux/) of the handbook.
To better understand the priority by which UX tackles issues, see the [UX section](https://about.gitlab.com/handbook/engineering/ux) of the handbook.
Once an issue has been worked on and is ready for development, a UXer applies the ~"UX ready" label to that issue.
......
......@@ -177,6 +177,7 @@ the stable branch are:
* Fixes for [regressions](#regressions)
* Fixes for security issues
* Fixes or improvements to automated QA scenarios
* Documentation updates for changes in the same release
* New or updated translations (as long as they do not touch application code)
During the feature freeze all merge requests that are meant to go into the
......
......@@ -27,7 +27,10 @@ gl.issueBoards.BoardSidebar = Vue.extend({
assignees,
removeBtn: gl.issueBoards.RemoveIssueBtn,
subscriptions,
<<<<<<< HEAD
weight,
=======
>>>>>>> upstream/master
},
props: {
currentUser: {
......
......@@ -12,7 +12,10 @@ window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.IssueCardInner = Vue.extend({
components: {
UserAvatarLink,
<<<<<<< HEAD
IssueCardWeight,
=======
>>>>>>> upstream/master
},
props: {
issue: {
......
......@@ -43,6 +43,15 @@ export default {
required: false,
default: false,
},
activeFileKey: {
type: String,
required: false,
default: null,
},
keyPrefix: {
type: String,
required: true,
},
},
data() {
return {
......@@ -113,8 +122,9 @@ export default {
<list-item
:file="file"
:action-component="itemActionComponent"
:key-prefix="title"
:key-prefix="keyPrefix"
:staged-list="stagedList"
:active-file-key="activeFileKey"
/>
</li>
</ul>
......
......@@ -30,6 +30,11 @@ export default {
required: false,
default: false,
},
activeFileKey: {
type: String,
required: false,
default: null,
},
},
computed: {
iconName() {
......@@ -39,6 +44,12 @@ export default {
iconClass() {
return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`;
},
fullKey() {
return `${this.keyPrefix}-${this.file.key}`;
},
isActive() {
return this.activeFileKey === this.fullKey;
},
},
methods: {
...mapActions([
......@@ -51,7 +62,7 @@ export default {
openFileInEditor() {
return this.openPendingTab({
file: this.file,
keyPrefix: this.keyPrefix.toLowerCase(),
keyPrefix: this.keyPrefix,
}).then(changeViewer => {
if (changeViewer) {
this.updateViewer(viewerTypes.diff);
......@@ -70,7 +81,12 @@ export default {
</script>
<template>
<div class="multi-file-commit-list-item">
<div
:class="{
'is-active': isActive
}"
class="multi-file-commit-list-item"
>
<button
type="button"
class="multi-file-commit-list-path"
......
......@@ -6,7 +6,7 @@ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import CommitFilesList from './commit_sidebar/list.vue';
import EmptyState from './commit_sidebar/empty_state.vue';
import * as consts from '../stores/modules/commit/constants';
import { activityBarViews } from '../constants';
import { activityBarViews, stageKeys } from '../constants';
export default {
components: {
......@@ -27,11 +27,14 @@ export default {
'unusedSeal',
]),
...mapState('commit', ['commitMessage', 'submitCommitLoading']),
...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommitedChanges']),
...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommitedChanges', 'activeFile']),
...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled']),
showStageUnstageArea() {
return !!(this.someUncommitedChanges || this.lastCommitMsg || !this.unusedSeal);
},
activeFileKey() {
return this.activeFile ? this.activeFile.key : null;
},
},
watch: {
hasChanges() {
......@@ -44,6 +47,7 @@ export default {
if (this.lastOpenedFile) {
this.openPendingTab({
file: this.lastOpenedFile,
keyPrefix: this.lastOpenedFile.changed ? stageKeys.unstaged : stageKeys.staged,
})
.then(changeViewer => {
if (changeViewer) {
......@@ -62,6 +66,7 @@ export default {
return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commitChanges());
},
},
stageKeys,
};
</script>
......@@ -86,8 +91,13 @@ export default {
>
<commit-files-list
:title="__('Unstaged')"
:key-prefix="$options.stageKeys.unstaged"
:file-list="changedFiles"
:action-btn-text="__('Stage all')"
<<<<<<< HEAD
=======
:active-file-key="activeFileKey"
>>>>>>> upstream/master
class="is-first"
icon-name="unstaged"
action="stageAllChanges"
......@@ -95,9 +105,14 @@ export default {
/>
<commit-files-list
:title="__('Staged')"
:key-prefix="$options.stageKeys.staged"
:file-list="stagedFiles"
:action-btn-text="__('Unstage all')"
:staged-list="true"
<<<<<<< HEAD
=======
:active-file-key="activeFileKey"
>>>>>>> upstream/master
icon-name="staged"
action="unstageAllChanges"
item-action-component="unstage-button"
......
......@@ -2,6 +2,7 @@
import { mapState, mapGetters, mapActions } from 'vuex';
import flash from '~/flash';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import { activityBarViews, viewerTypes } from '../constants';
import Editor from '../lib/editor';
import ExternalLink from './external_link.vue';
......@@ -9,6 +10,7 @@ import ExternalLink from './external_link.vue';
export default {
components: {
ContentViewer,
DiffViewer,
ExternalLink,
},
props: {
......@@ -29,9 +31,18 @@ export default {
shouldHideEditor() {
return this.file && this.file.binary && !this.file.content;
},
showContentViewer() {
return (
(this.shouldHideEditor || this.file.viewMode === 'preview') &&
(this.viewer !== viewerTypes.mr || !this.file.mrChange)
);
},
showDiffViewer() {
return this.shouldHideEditor && this.file.mrChange && this.viewer === viewerTypes.mr;
},
editTabCSS() {
return {
active: this.file.viewMode === 'edit',
active: this.file.viewMode === 'editor',
};
},
previewTabCSS() {
......@@ -53,7 +64,7 @@ export default {
if (this.currentActivityView !== activityBarViews.edit) {
this.setFileViewMode({
file: this.file,
viewMode: 'edit',
viewMode: 'editor',
});
}
}
......@@ -62,7 +73,7 @@ export default {
if (this.currentActivityView !== activityBarViews.edit) {
this.setFileViewMode({
file: this.file,
viewMode: 'edit',
viewMode: 'editor',
});
}
},
......@@ -197,7 +208,7 @@ export default {
<a
href="javascript:void(0);"
role="button"
@click.prevent="setFileViewMode({ file, viewMode: 'edit' })">
@click.prevent="setFileViewMode({ file, viewMode: 'editor' })">
<template v-if="viewer === $options.viewerTypes.edit">
{{ __('Edit') }}
</template>
......@@ -222,7 +233,7 @@ export default {
/>
</div>
<div
v-show="!shouldHideEditor && file.viewMode === 'edit'"
v-show="!shouldHideEditor && file.viewMode ==='editor'"
ref="editor"
:class="{
'is-readonly': isCommitModeActive,
......@@ -231,10 +242,18 @@ export default {
>
</div>
<content-viewer
v-if="shouldHideEditor || file.viewMode === 'preview'"
v-if="showContentViewer"
:content="file.content || file.raw"
:path="file.rawPath || file.path"
:file-size="file.size"
:project-path="file.projectId"/>
<diff-viewer
v-if="showDiffViewer"
:diff-mode="file.mrChange.diffMode"
:new-path="file.mrChange.new_path"
:new-sha="currentMergeRequest.sha"
:old-path="file.mrChange.old_path"
:old-sha="currentMergeRequest.baseCommitSha"
:project-path="file.projectId"/>
</div>
</template>
......@@ -21,7 +21,19 @@ export const viewerTypes = {
diff: 'diff',
};
export const diffModes = {
replaced: 'replaced',
new: 'new',
deleted: 'deleted',
renamed: 'renamed',
};
export const rightSidebarViews = {
pipelines: 'pipelines-list',
jobsDetail: 'jobs-detail',
};
export const stageKeys = {
unstaged: 'unstaged',
staged: 'staged',
};
/* eslint-disable no-param-reassign */
import * as types from '../mutation_types';
import { diffModes } from '../../constants';
export default {
[types.SET_FILE_ACTIVE](state, { path, active }) {
......@@ -85,8 +86,19 @@ export default {
});
},
[types.SET_FILE_MERGE_REQUEST_CHANGE](state, { file, mrChange }) {
let diffMode = diffModes.replaced;
if (mrChange.new_file) {
diffMode = diffModes.new;
} else if (mrChange.deleted_file) {
diffMode = diffModes.deleted;
} else if (mrChange.renamed_file) {
diffMode = diffModes.renamed;
}
Object.assign(state.entries[file.path], {
mrChange,
mrChange: {
...mrChange,
diffMode,
},
});
},
[types.SET_FILE_VIEWMODE](state, { file, viewMode }) {
......
......@@ -39,7 +39,7 @@ export const dataStructure = () => ({
editorColumn: 1,
fileLanguage: '',
eol: '',
viewMode: 'edit',
viewMode: 'editor',
previewMode: null,
size: 0,
parentPath: null,
......
/* eslint-disable no-param-reassign, comma-dangle */
/* eslint-disable no-param-reassign */
import Vue from 'vue';
import actionsMixin from '../mixins/line_conflict_actions';
import utilsMixin from '../mixins/line_conflict_utils';
((global) => {
(global => {
global.mergeConflicts = global.mergeConflicts || {};
global.mergeConflicts.inlineConflictLines = Vue.extend({
<<<<<<< HEAD
mixins: [global.mergeConflicts.utils, global.mergeConflicts.actions],
=======
mixins: [utilsMixin, actionsMixin],
>>>>>>> upstream/master
props: {
file: {
type: Object,
......
/* eslint-disable no-param-reassign, comma-dangle */
import Vue from 'vue';
import actionsMixin from '../mixins/line_conflict_actions';
import utilsMixin from '../mixins/line_conflict_utils';
((global) => {
global.mergeConflicts = global.mergeConflicts || {};
global.mergeConflicts.parallelConflictLines = Vue.extend({
<<<<<<< HEAD
mixins: [global.mergeConflicts.utils, global.mergeConflicts.actions],
=======
mixins: [utilsMixin, actionsMixin],
>>>>>>> upstream/master
props: {
file: {
type: Object,
......
/* eslint-disable no-param-reassign, comma-dangle */
import axios from '../lib/utils/axios_utils';
((global) => {
global.mergeConflicts = global.mergeConflicts || {};
class mergeConflictsService {
constructor(options) {
this.conflictsPath = options.conflictsPath;
this.resolveConflictsPath = options.resolveConflictsPath;
}
fetchConflictsData() {
return axios.get(this.conflictsPath);
}
export default class MergeConflictsService {
constructor(options) {
this.conflictsPath = options.conflictsPath;
this.resolveConflictsPath = options.resolveConflictsPath;
}
submitResolveConflicts(data) {
return axios.post(this.resolveConflictsPath, data);
}
fetchConflictsData() {
return axios.get(this.conflictsPath);
}
global.mergeConflicts.mergeConflictsService = mergeConflictsService;
})(window.gl || (window.gl = {}));
submitResolveConflicts(data) {
return axios.post(this.resolveConflictsPath, data);
}
}
......@@ -5,7 +5,7 @@ import Vue from 'vue';
import Flash from '../flash';
import initIssuableSidebar from '../init_issuable_sidebar';
import './merge_conflict_store';
import './merge_conflict_service';
import MergeConflictsService from './merge_conflict_service';
import './mixins/line_conflict_utils';
import './mixins/line_conflict_actions';
import './components/diff_file_editor';
......@@ -17,7 +17,7 @@ export default function initMergeConflicts() {
const INTERACTIVE_RESOLVE_MODE = 'interactive';
const conflictsEl = document.querySelector('#conflicts');
const mergeConflictsStore = gl.mergeConflicts.mergeConflictsStore;
const mergeConflictsService = new gl.mergeConflicts.mergeConflictsService({
const mergeConflictsService = new MergeConflictsService({
conflictsPath: conflictsEl.dataset.conflictsPath,
resolveConflictsPath: conflictsEl.dataset.resolveConflictsPath
});
......
/* eslint-disable no-param-reassign, comma-dangle */
((global) => {
global.mergeConflicts = global.mergeConflicts || {};
global.mergeConflicts.actions = {
methods: {
handleSelected(file, sectionId, selection) {
gl.mergeConflicts.mergeConflictsStore.handleSelected(file, sectionId, selection);
}
}
};
})(window.gl || (window.gl = {}));
export default {
methods: {
handleSelected(file, sectionId, selection) {
gl.mergeConflicts.mergeConflictsStore.handleSelected(file, sectionId, selection);
},
},
};
/* eslint-disable no-param-reassign, quote-props, comma-dangle */
((global) => {
global.mergeConflicts = global.mergeConflicts || {};
global.mergeConflicts.utils = {
methods: {
lineCssClass(line) {
return {
'head': line.isHead,
'origin': line.isOrigin,
'match': line.hasMatch,
'selected': line.isSelected,
'unselected': line.isUnselected
};
}
}
};
})(window.gl || (window.gl = {}));
export default {
methods: {
lineCssClass(line) {
return {
head: line.isHead,
origin: line.isOrigin,
match: line.hasMatch,
selected: line.isSelected,
unselected: line.isUnselected,
};
},
},
};
......@@ -56,7 +56,7 @@ export default class MilestoneSelect {
if (issueUpdateURL) {
milestoneLinkTemplate = _.template(
'<a href="/<%- full_path %>/milestones/<%- iid %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>',
'<a href="<%- web_url %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>',
);
milestoneLinkNoneTemplate = '<span class="no-value">None</span>';
}
......
......@@ -62,13 +62,13 @@ export default class UsernameValidator {
return this.setPendingState();
}
if (!this.state.available) {
return this.setUnavailableState();
}
if (!this.state.valid) {
return this.setInvalidState();
}
if (!this.state.available) {
return this.setUnavailableState();
}
}
interceptInvalid(event) {
......@@ -89,7 +89,6 @@ export default class UsernameValidator {
setAvailabilityState(usernameTaken) {
if (usernameTaken) {
this.state.valid = false;
this.state.available = false;
} else {
this.state.available = true;
......
......@@ -67,9 +67,13 @@ export default {
<li
v-for="(job, index) in jobs"
:key="job.id"
<<<<<<< HEAD
:class="{
'left-connector': index === 0 && (!isFirstColumn || hasTriggeredBy)
}"
=======
:class="buildConnnectorClass(index)"
>>>>>>> upstream/master
:id="jobId(job)"
class="build"
>
......
......@@ -58,6 +58,12 @@ export default {
:title="timeRemainingTooltip"
:class="timeRemainingStatusClass"
class="compare-meter"
<<<<<<< HEAD
=======
data-toggle="tooltip"
data-placement="top"
role="timeRemainingDisplay"
>>>>>>> upstream/master
>
<div
:aria-valuenow="timeRemainingPercent"
......
......@@ -109,6 +109,7 @@ export default {
</template>
<span class="mr-widget-pipeline-graph">
<<<<<<< HEAD
<span class="stage-cell">
<linked-pipelines-mini-list
v-if="triggeredBy.length"
......@@ -131,6 +132,19 @@ export default {
v-if="triggered.length"
:triggered="triggered"
/>
=======
<span
v-if="hasStages"
class="stage-cell"
>
<div
v-for="(stage, i) in pipeline.details.stages"
:key="i"
class="stage-container dropdown js-mini-pipeline-graph"
>
<pipeline-stage :stage="stage" />
</div>
>>>>>>> upstream/master
</span>
</span>
......
......@@ -32,7 +32,10 @@ export default {
<div class="file-container">
<div class="file-content">
<p class="prepend-top-10 file-info">
{{ fileName }} ({{ fileSizeReadable }})
{{ fileName }}
<template v-if="fileSize > 0">
({{ fileSizeReadable }})
</template>
</p>
<a
:href="path"
......
<script>
import _ from 'underscore';
import { numberToHumanSize } from '../../../../lib/utils/number_utils';
export default {
......@@ -12,6 +13,10 @@ export default {
required: false,
default: 0,
},
renderInfo: {
type: Boolean,
default: true,
},
},
data() {
return {
......@@ -26,14 +31,34 @@ export default {
return numberToHumanSize(this.fileSize);
},
},
beforeDestroy() {
window.removeEventListener('resize', this.resizeThrottled, false);
},
mounted() {
// The onImgLoad may have happened before the control was actually mounted
this.onImgLoad();
this.resizeThrottled = _.throttle(this.onImgLoad, 400);
window.addEventListener('resize', this.resizeThrottled, false);
},
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;
if (contentImg) {
this.isZoomable =
contentImg.naturalWidth > contentImg.width ||
contentImg.naturalHeight > contentImg.height;
this.width = contentImg.naturalWidth;
this.height = contentImg.naturalHeight;
this.$emit('imgLoaded', {
width: this.width,
height: this.height,
renderedWidth: contentImg.clientWidth,
renderedHeight: contentImg.clientHeight,
});
}
},
onImgClick() {
if (this.isZoomable) this.isZoomed = !this.isZoomed;
......@@ -47,20 +72,22 @@ export default {
<div class="file-content image_file">
<img
ref="contentImg"
:class="{ 'isZoomable': isZoomable, 'isZoomed': isZoomed }"
:class="{ 'is-zoomable': isZoomable, 'is-zoomed': isZoomed }"
:src="path"
:alt="path"
@load="onImgLoad"
@click="onImgClick"/>
<p class="file-info prepend-top-10">
<p
v-if="renderInfo"
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 }}
W: {{ width }} | H: {{ height }}
</template>
</p>
</div>
......
export const diffModes = {
replaced: 'replaced',
new: 'new',
deleted: 'deleted',
renamed: 'renamed',
};
export const imageViewMode = {
twoup: 'twoup',
swipe: 'swipe',
onion: 'onion',
};
<script>
import { viewerInformationForPath } from '../content_viewer/lib/viewer_utils';
import ImageDiffViewer from './viewers/image_diff_viewer.vue';
import DownloadDiffViewer from './viewers/download_diff_viewer.vue';
export default {
props: {
diffMode: {
type: String,
required: true,
},
newPath: {
type: String,
required: true,
},
newSha: {
type: String,
required: true,
},
oldPath: {
type: String,
required: true,
},
oldSha: {
type: String,
required: true,
},
projectPath: {
type: String,
required: false,
default: '',
},
},
computed: {
viewer() {
if (!this.newPath) return null;
const previewInfo = viewerInformationForPath(this.newPath);
if (!previewInfo) return DownloadDiffViewer;
switch (previewInfo.id) {
case 'image':
return ImageDiffViewer;
default:
return DownloadDiffViewer;
}
},
fullOldPath() {
return `${gon.relative_url_root}/${this.projectPath}/raw/${this.oldSha}/${this.oldPath}`;
},
fullNewPath() {
return `${gon.relative_url_root}/${this.projectPath}/raw/${this.newSha}/${this.newPath}`;
},
},
};
</script>
<template>
<div
v-if="viewer"
class="diff-file preview-container">
<component
:is="viewer"
:diff-mode="diffMode"
:new-path="fullNewPath"
:old-path="fullOldPath"
:project-path="projectPath"
/>
</div>
</template>
<script>
import DownloadViewer from '../../content_viewer/viewers/download_viewer.vue';
import { diffModes } from '../constants';
export default {
components: {
DownloadViewer,
},
props: {
diffMode: {
type: String,
required: true,
},
newPath: {
type: String,
required: true,
},
oldPath: {
type: String,
required: true,
},
projectPath: {
type: String,
required: false,
default: '',
},
},
diffModes,
};
</script>
<template>
<div class="diff-file-container">
<div class="diff-viewer">
<div
v-if="diffMode === $options.diffModes.replaced"
class="two-up view row">
<div class="col-sm-6 deleted">
<download-viewer
:path="oldPath"
:project-path="projectPath"
/>
</div>
<div class="col-sm-6 added">
<download-viewer
:path="newPath"
:project-path="projectPath"
/>
</div>
</div>
<div
v-else-if="diffMode === $options.diffModes.new"
class="added">
<download-viewer
:path="newPath"
:project-path="projectPath"
/>
</div>
<div
v-else
class="deleted">
<download-viewer
:path="oldPath"
:project-path="projectPath"
/>
</div>
</div>
</div>
</template>
<script>
import { pixeliseValue } from '../../../lib/utils/dom_utils';
import ImageViewer from '../../../content_viewer/viewers/image_viewer.vue';
export default {
components: {
ImageViewer,
},
props: {
newPath: {
type: String,
required: true,
},
oldPath: {
type: String,
required: true,
},
projectPath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
onionMaxWidth: undefined,
onionMaxHeight: undefined,
onionOldImgInfo: null,
onionNewImgInfo: null,
onionDraggerPos: 0,
onionOpacity: 1,
dragging: false,
};
},
computed: {
onionMaxPixelWidth() {
return pixeliseValue(this.onionMaxWidth);
},
onionMaxPixelHeight() {
return pixeliseValue(this.onionMaxHeight);
},
onionDraggerPixelPos() {
return pixeliseValue(this.onionDraggerPos);
},
},
beforeDestroy() {
document.body.removeEventListener('mouseup', this.stopDrag);
this.$refs.dragger.removeEventListener('mousedown', this.startDrag);
},
methods: {
dragMove(e) {
if (!this.dragging) return;
const left = e.pageX - this.$refs.dragTrack.getBoundingClientRect().left;
const dragTrackWidth =
this.$refs.dragTrack.clientWidth - this.$refs.dragger.clientWidth || 100;
let leftValue = left;
if (leftValue < 0) leftValue = 0;
if (leftValue > dragTrackWidth) leftValue = dragTrackWidth;
this.onionOpacity = left / dragTrackWidth;
this.onionDraggerPos = leftValue;
},
startDrag() {
this.dragging = true;
document.body.style.userSelect = 'none';
document.body.addEventListener('mousemove', this.dragMove);
},
stopDrag() {
this.dragging = false;
document.body.style.userSelect = '';
document.body.removeEventListener('mousemove', this.dragMove);
},
prepareOnionSkin() {
if (this.onionOldImgInfo && this.onionNewImgInfo) {
this.onionMaxWidth = Math.max(
this.onionOldImgInfo.renderedWidth,
this.onionNewImgInfo.renderedWidth,
);
this.onionMaxHeight = Math.max(
this.onionOldImgInfo.renderedHeight,
this.onionNewImgInfo.renderedHeight,
);
this.onionOpacity = 1;
this.onionDraggerPos =
this.$refs.dragTrack.clientWidth - this.$refs.dragger.clientWidth || 100;
document.body.addEventListener('mouseup', this.stopDrag);
}
},
onionNewImgLoaded(imgInfo) {
this.onionNewImgInfo = imgInfo;
this.prepareOnionSkin();
},
onionOldImgLoaded(imgInfo) {
this.onionOldImgInfo = imgInfo;
this.prepareOnionSkin();
},
},
};
</script>
<template>
<div class="onion-skin view">
<div
:style="{
'width': onionMaxPixelWidth,
'height': onionMaxPixelHeight,
'user-select': dragging === true ? 'none' : '',
}"
class="onion-skin-frame">
<div
:style="{
'width': onionMaxPixelWidth,
'height': onionMaxPixelHeight,
}"
class="frame deleted">
<image-viewer
key="onionOldImg"
:render-info="false"
:path="oldPath"
:project-path="projectPath"
@imgLoaded="onionOldImgLoaded"
/>
</div>
<div
ref="addedFrame"
:style="{
'opacity': onionOpacity,
'width': onionMaxPixelWidth,
'height': onionMaxPixelHeight,
}"
class="added frame">
<image-viewer
key="onionNewImg"
:render-info="false"
:path="newPath"
:project-path="projectPath"
@imgLoaded="onionNewImgLoaded"
/>
</div>
<div class="controls">
<div class="transparent"></div>
<div
ref="dragTrack"
class="drag-track"
@mousedown="startDrag"
@mouseup="stopDrag">
<div
ref="dragger"
:style="{ 'left': onionDraggerPixelPos }"
class="dragger">
</div>
</div>
<div class="opaque"></div>
</div>
</div>
</div>
</template>
<script>
import _ from 'underscore';
import { pixeliseValue } from '../../../lib/utils/dom_utils';
import ImageViewer from '../../../content_viewer/viewers/image_viewer.vue';
export default {
components: {
ImageViewer,
},
props: {
newPath: {
type: String,
required: true,
},
oldPath: {
type: String,
required: true,
},
projectPath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
dragging: false,
swipeOldImgInfo: null,
swipeNewImgInfo: null,
swipeMaxWidth: undefined,
swipeMaxHeight: undefined,
swipeBarPos: 1,
swipeWrapWidth: undefined,
};
},
computed: {
swipeMaxPixelWidth() {
return pixeliseValue(this.swipeMaxWidth);
},
swipeMaxPixelHeight() {
return pixeliseValue(this.swipeMaxHeight);
},
swipeWrapPixelWidth() {
return pixeliseValue(this.swipeWrapWidth);
},
swipeBarPixelPos() {
return pixeliseValue(this.swipeBarPos);
},
},
beforeDestroy() {
window.removeEventListener('resize', this.resizeThrottled, false);
document.body.removeEventListener('mouseup', this.stopDrag);
document.body.removeEventListener('mousemove', this.dragMove);
},
mounted() {
window.addEventListener('resize', this.resize, false);
},
methods: {
dragMove(e) {
if (!this.dragging) return;
let leftValue = e.pageX - this.$refs.swipeFrame.getBoundingClientRect().left;
const spaceLeft = 20;
const { clientWidth } = this.$refs.swipeFrame;
if (leftValue <= 0) {
leftValue = 0;
} else if (leftValue > clientWidth - spaceLeft) {
leftValue = clientWidth - spaceLeft;
}
this.swipeWrapWidth = this.swipeMaxWidth - leftValue;
this.swipeBarPos = leftValue;
},
startDrag() {
this.dragging = true;
document.body.style.userSelect = 'none';
document.body.addEventListener('mousemove', this.dragMove);
},
stopDrag() {
this.dragging = false;
document.body.style.userSelect = '';
document.body.removeEventListener('mousemove', this.dragMove);
},
prepareSwipe() {
if (this.swipeOldImgInfo && this.swipeNewImgInfo) {
// Add 2 for border width
this.swipeMaxWidth =
Math.max(this.swipeOldImgInfo.renderedWidth, this.swipeNewImgInfo.renderedWidth) + 2;
this.swipeWrapWidth = this.swipeMaxWidth;
this.swipeMaxHeight =
Math.max(this.swipeOldImgInfo.renderedHeight, this.swipeNewImgInfo.renderedHeight) + 2;
document.body.addEventListener('mouseup', this.stopDrag);
}
},
swipeNewImgLoaded(imgInfo) {
this.swipeNewImgInfo = imgInfo;
this.prepareSwipe();
},
swipeOldImgLoaded(imgInfo) {
this.swipeOldImgInfo = imgInfo;
this.prepareSwipe();
},
resize: _.throttle(function throttledResize() {
this.swipeBarPos = 0;
}, 400),
},
};
</script>
<template>
<div class="swipe view">
<div
ref="swipeFrame"
:style="{
'width': swipeMaxPixelWidth,
'height': swipeMaxPixelHeight,
}"
class="swipe-frame">
<div class="frame deleted">
<image-viewer
key="swipeOldImg"
ref="swipeOldImg"
:render-info="false"
:path="oldPath"
:project-path="projectPath"
@imgLoaded="swipeOldImgLoaded"
/>
</div>
<div
ref="swipeWrap"
:style="{
'width': swipeWrapPixelWidth,
'height': swipeMaxPixelHeight,
}"
class="swipe-wrap">
<div class="frame added">
<image-viewer
key="swipeNewImg"
:render-info="false"
:path="newPath"
:project-path="projectPath"
@imgLoaded="swipeNewImgLoaded"
/>
</div>
</div>
<span
ref="swipeBar"
:style="{ 'left': swipeBarPixelPos }"
class="swipe-bar"
@mousedown="startDrag"
@mouseup="stopDrag">
<span class="top-handle"></span>
<span class="bottom-handle"></span>
</span>
</div>
</div>
</template>
<script>
import ImageViewer from '../../../content_viewer/viewers/image_viewer.vue';
export default {
components: {
ImageViewer,
},
props: {
newPath: {
type: String,
required: true,
},
oldPath: {
type: String,
required: true,
},
projectPath: {
type: String,
required: false,
default: '',
},
},
};
</script>
<template>
<div class="two-up view row">
<div class="col-sm-6 frame deleted">
<image-viewer
:path="oldPath"
:project-path="projectPath"
/>
</div>
<div class="col-sm-6 frame added">
<image-viewer
:path="newPath"
:project-path="projectPath"
/>
</div>
</div>
</template>
<script>
import ImageViewer from '../../content_viewer/viewers/image_viewer.vue';
import TwoUpViewer from './image_diff/two_up_viewer.vue';
import SwipeViewer from './image_diff/swipe_viewer.vue';
import OnionSkinViewer from './image_diff/onion_skin_viewer.vue';
import { diffModes, imageViewMode } from '../constants';
export default {
components: {
ImageViewer,
TwoUpViewer,
SwipeViewer,
OnionSkinViewer,
},
props: {
diffMode: {
type: String,
required: true,
},
newPath: {
type: String,
required: true,
},
oldPath: {
type: String,
required: true,
},
projectPath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
mode: imageViewMode.twoup,
};
},
methods: {
changeMode(newMode) {
this.mode = newMode;
},
},
diffModes,
imageViewMode,
};
</script>
<template>
<div class="diff-file-container">
<div
v-if="diffMode === $options.diffModes.replaced"
class="diff-viewer">
<div class="image js-replaced-image">
<two-up-viewer
v-if="mode === $options.imageViewMode.twoup"
v-bind="$props"/>
<swipe-viewer
v-else-if="mode === $options.imageViewMode.swipe"
v-bind="$props"/>
<onion-skin-viewer
v-else-if="mode === $options.imageViewMode.onion"
v-bind="$props"/>
</div>
<div class="view-modes">
<ul class="view-modes-menu">
<li
:class="{
active: mode === $options.imageViewMode.twoup
}"
@click="changeMode($options.imageViewMode.twoup)">
{{ s__('ImageDiffViewer|2-up') }}
</li>
<li
:class="{
active: mode === $options.imageViewMode.swipe
}"
@click="changeMode($options.imageViewMode.swipe)">
{{ s__('ImageDiffViewer|Swipe') }}
</li>
<li
:class="{
active: mode === $options.imageViewMode.onion
}"
@click="changeMode($options.imageViewMode.onion)">
{{ s__('ImageDiffViewer|Onion skin') }}
</li>
</ul>
</div>
<div class="note-container"></div>
</div>
<div
v-else-if="diffMode === $options.diffModes.new"
class="diff-viewer added">
<image-viewer
:path="newPath"
:project-path="projectPath"
/>
</div>
<div
v-else
class="diff-viewer deleted">
<image-viewer
:path="oldPath"
:project-path="projectPath"
/>
</div>
</div>
</template>
export function pixeliseValue(val) {
return val ? `${val}px` : '';
}
export default {};
......@@ -41,7 +41,10 @@ export default {
class="sidebar-collapsed-icon"
data-placement="left"
data-container="body"
<<<<<<< HEAD
data-boundary="viewport"
=======
>>>>>>> upstream/master
@click="handleClick"
>
<i
......
......@@ -34,7 +34,10 @@ export default {
class="btn btn-blank gutter-toggle btn-sidebar-action"
data-container="body"
data-placement="left"
<<<<<<< HEAD
data-boundary="viewport"
=======
>>>>>>> upstream/master
@click="toggle"
>
<i
......
......@@ -99,5 +99,9 @@ export default {
:data-placement="tooltipPlacement"
:title="tooltipText"
class="avatar"
<<<<<<< HEAD
=======
data-boundary="window"
>>>>>>> upstream/master
/>
</template>
......@@ -400,3 +400,51 @@ span.idiff {
color: $common-gray-light;
border: 1px solid $common-gray-light;
}
.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: 70vh;
}
.is-zoomable {
cursor: pointer;
cursor: zoom-in;
&.is-zoomed {
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;
}
}
......@@ -186,6 +186,7 @@
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
display: flex;
flex-wrap: nowrap;
&::-webkit-scrollbar {
display: none;
......
......@@ -257,6 +257,8 @@
}
.scrolling-tabs-container {
position: relative;
.merge-request-tabs-container & {
overflow: hidden;
}
......
......@@ -58,7 +58,7 @@ table {
display: none;
}
table,
&,
tbody,
td {
display: block;
......
......@@ -189,8 +189,22 @@
img {
border: 1px solid $white-light;
background-image: linear-gradient(45deg, $border-color 25%, transparent 25%, transparent 75%, $border-color 75%, $border-color 100%),
linear-gradient(45deg, $border-color 25%, transparent 25%, transparent 75%, $border-color 75%, $border-color 100%);
background-image: linear-gradient(
45deg,
$border-color 25%,
transparent 25%,
transparent 75%,
$border-color 75%,
$border-color 100%
),
linear-gradient(
45deg,
$border-color 25%,
transparent 25%,
transparent 75%,
$border-color 75%,
$border-color 100%
);
background-size: 10px 10px;
background-position: 0 0, 5px 5px;
max-width: 100%;
......@@ -395,6 +409,69 @@
.line_content {
white-space: pre-wrap;
}
.diff-file-container {
.frame.deleted {
border: 0;
background-color: inherit;
.image_file img {
border: 1px solid $deleted;
}
}
.frame.added {
border: 0;
background-color: inherit;
.image_file img {
border: 1px solid $added;
}
}
.swipe.view,
.onion-skin.view {
.swipe-wrap {
top: 0;
right: 0;
}
.frame.deleted {
top: 0;
right: 0;
}
.swipe-bar {
top: 0;
.top-handle {
top: -14px;
left: -7px;
}
.bottom-handle {
bottom: -14px;
left: -7px;
}
}
.file-container {
display: inline-block;
.file-content {
padding: 0;
img {
max-width: none;
}
}
}
}
.onion-skin.view .controls {
bottom: -25px;
}
}
}
.file-content .diff-file {
......@@ -536,7 +613,7 @@
margin-right: 0;
border-color: $white-light;
cursor: pointer;
transition: all .1s ease-out;
transition: all 0.1s ease-out;
@for $i from 1 through 4 {
&:nth-child(#{$i}) {
......@@ -563,7 +640,7 @@
height: 24px;
border-radius: 50%;
padding: 0;
transition: transform .1s ease-out;
transition: transform 0.1s ease-out;
z-index: 100;
.collapse-icon {
......@@ -708,11 +785,35 @@
width: 100%;
height: 10px;
background-color: $white-light;
background-image: linear-gradient(45deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%),
linear-gradient(225deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%),
linear-gradient(135deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%),
linear-gradient(-45deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%);
background-position: 5px 5px,0 5px,0 5px,5px 5px;
background-image: linear-gradient(
45deg,
transparent,
transparent 73%,
$diff-jagged-border-gradient-color 75%,
$white-light 80%
),
linear-gradient(
225deg,
transparent,
transparent 73%,
$diff-jagged-border-gradient-color 75%,
$white-light 80%
),
linear-gradient(
135deg,
transparent,
transparent 73%,
$diff-jagged-border-gradient-color 75%,
$white-light 80%
),
linear-gradient(
-45deg,
transparent,
transparent 73%,
$diff-jagged-border-gradient-color 75%,
$white-light 80%
);
background-position: 5px 5px, 0 5px, 0 5px, 5px 5px;
background-size: 10px 10px;
background-repeat: repeat;
}
......@@ -750,11 +851,16 @@
.frame.click-to-comment {
position: relative;
cursor: image-url('illustrations/image_comment_light_cursor.svg')
$image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto;
$image-comment-cursor-left-offset $image-comment-cursor-top-offset,
auto;
// Retina cursor
cursor: -webkit-image-set(image-url('illustrations/image_comment_light_cursor.svg') 1x, image-url('illustrations/image_comment_light_cursor@2x.svg') 2x)
$image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto;
cursor: -webkit-image-set(
image-url('illustrations/image_comment_light_cursor.svg') 1x,
image-url('illustrations/image_comment_light_cursor@2x.svg') 2x
)
$image-comment-cursor-left-offset $image-comment-cursor-top-offset,
auto;
.comment-indicator {
position: absolute;
......@@ -840,7 +946,7 @@
.diff-notes-collapse,
.note,
.discussion-reply-holder, {
.discussion-reply-holder {
display: none;
}
......
......@@ -670,6 +670,7 @@
.merge-request-tabs {
display: flex;
flex-wrap: nowrap;
margin-bottom: 0;
padding: 0;
}
......
......@@ -354,12 +354,6 @@
min-width: 200px;
}
.deploy-keys {
.scrolling-tabs-container {
position: relative;
}
}
.deploy-key {
// Ensure that the fingerprint does not overflow on small screens
.fingerprint {
......
......@@ -335,7 +335,6 @@
img {
max-width: 90%;
max-height: 90%;
}
.isZoomable {
......@@ -553,6 +552,10 @@
}
.multi-file-commit-list-item {
&.is-active {
background-color: $white-normal;
}
.multi-file-discard-btn {
display: none;
margin-top: -2px;
......
......@@ -292,8 +292,10 @@ class ApplicationController < ActionController::Base
return unless current_user
return if current_user.terms_accepted?
message = _("Please accept the Terms of Service before continuing.")
if sessionless_user?
render_403
access_denied!(message)
else
# Redirect to the destination if the request is a get.
# Redirect to the source if it was a post, so the user can re-submit after
......@@ -304,7 +306,7 @@ class ApplicationController < ActionController::Base
URI(request.referer).path if request.referer
end
flash[:notice] = _("Please accept the Terms of Service before continuing.")
flash[:notice] = message
redirect_to terms_path(redirect: redirect_path), status: :found
end
end
......
......@@ -562,6 +562,17 @@ module QuickActions
end
end
desc 'Make issue confidential.'
explanation do
'Makes this issue confidential'
end
condition do
issuable.is_a?(Issue) && current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
end
command :confidential do
@updates[:confidential] = true
end
def extract_users(params)
return [] if params.nil?
......
......@@ -73,6 +73,15 @@ module ObjectStorage
upload.id)
end
def exclusive_lease_key
# For FileUploaders, model may have many uploaders. In that case
# we want to use exclusive key per upload, not per model to allow
# parallel migration
key_object = upload || model
"object_storage_migrate:#{key_object.class}:#{key_object.id}"
end
private
def current_upload_satisfies?(paths, model)
......@@ -316,6 +325,10 @@ module ObjectStorage
super
end
def exclusive_lease_key
"object_storage_migrate:#{model.class}:#{model.id}"
end
private
def schedule_background_upload?
......@@ -382,10 +395,6 @@ module ObjectStorage
end
end
def exclusive_lease_key
"object_storage_migrate:#{model.class}:#{model.id}"
end
def with_exclusive_lease
lease_key = exclusive_lease_key
uuid = Gitlab::ExclusiveLease.new(lease_key, timeout: 1.hour.to_i).try_obtain
......
......@@ -12,9 +12,9 @@
- if can?(current_user, :award_emoji, awardable)
.award-menu-holder.js-award-holder
%button.btn.award-control.has-tooltip.js-add-award{ type: 'button',
'aria-label': 'Add reaction',
'aria-label': _('Add reaction'),
class: ("js-user-authored" if user_authored),
data: { title: 'Add reaction', placement: "bottom" } }
data: { title: _('Add reaction'), placement: "bottom" } }
%span{ class: "award-control-icon award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face')
%span{ class: "award-control-icon award-control-icon-positive" }= custom_icon('emoji_smiley')
%span{ class: "award-control-icon award-control-icon-super-positive" }= custom_icon('emoji_smile')
......
- message = local_assigns.fetch(:message)
- message = local_assigns.fetch(:message, nil)
- content_for(:title, 'Access Denied')
= image_tag('illustrations/error-403.svg', alt: '403', lazy: false)
......
......@@ -32,26 +32,25 @@
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
.fade-left= icon('angle-left')
.fade-right= icon('angle-right')
.nav-links.scrolling-tabs.nav.nav-tabs
%ul.merge-request-tabs.nav-tabs.nav
%li.notes-tab
= tab_link_for @merge_request, :show, force_link: @commit.present? do
Discussion
%span.badge.badge-pill= @merge_request.related_notes.user.count
- if @merge_request.source_project
%li.commits-tab
= tab_link_for @merge_request, :commits do
Commits
%span.badge.badge-pill= @commits_count
- if @pipelines.any?
%li.pipelines-tab
= tab_link_for @merge_request, :pipelines do
Pipelines
%span.badge.badge-pill.js-pipelines-mr-count= @pipelines.size
%li.diffs-tab
= tab_link_for @merge_request, :diffs do
Changes
%span.badge.badge-pill= @merge_request.diff_size
%ul.merge-request-tabs.nav-tabs.nav.nav-links.scrolling-tabs
%li.notes-tab
= tab_link_for @merge_request, :show, force_link: @commit.present? do
Discussion
%span.badge.badge-pill= @merge_request.related_notes.user.count
- if @merge_request.source_project
%li.commits-tab
= tab_link_for @merge_request, :commits do
Commits
%span.badge.badge-pill= @commits_count
- if @pipelines.any?
%li.pipelines-tab
= tab_link_for @merge_request, :pipelines do
Pipelines
%span.badge.badge-pill.js-pipelines-mr-count= @pipelines.size
%li.diffs-tab
= tab_link_for @merge_request, :diffs do
Changes
%span.badge.badge-pill= @merge_request.diff_size
- if has_vue_discussions_cookie?
#js-vue-discussion-counter
......
---
title: Expose visibility via Snippets API
merge_request: 19620
author: Jan Beckmann
type: added
---
title: 'Fix username validation order on signup, resolves #45575'
merge_request: 19610
author: Jan Beckmann
type: fixed
---
title: Make quick commands case insensitive
merge_request: 19614
author: Jan Beckmann
type: fixed
---
title: Add /confidential quick action
merge_request:
author: Jan Beckmann
type: added
---
title: Use upload ID for creating lease key for file uploaders.
merge_request:
author:
type: fixed
---
title: Add support for verifying remote uploads, artifacts, and LFS objects in check rake tasks
merge_request: 19501
author:
type: added
---
title: Adjust SQL and transaction Prometheus buckets
merge_request:
author:
type: other
---
title: Web IDE supports now Image + Download Diff Viewing
merge_request: 18768
author:
type: added
......@@ -78,9 +78,10 @@ Example output:
## Uploaded Files Integrity
Various types of file can be uploaded to a GitLab installation by users.
Checksums are generated and stored in the database upon upload, and integrity
checks using those checksums can be run. These checks also detect missing files.
Various types of files can be uploaded to a GitLab installation by users.
These integrity checks can detect missing files. Additionally, for locally
stored files, checksums are generated and stored in the database upon upload,
and these checks will verify them against current files.
Currently, integrity checks are supported for the following types of file:
......
......@@ -49,6 +49,7 @@ Example response:
"title": "test",
"file_name": "add.rb",
"description": "Ruby test snippet",
"visibility": "private",
"author": {
"id": 1,
"username": "john_smith",
......@@ -99,6 +100,7 @@ Example response:
"title": "This is a snippet",
"file_name": "test.txt",
"description": "Hello World snippet",
"visibility": "internal",
"author": {
"id": 1,
"username": "john_smith",
......@@ -150,6 +152,7 @@ Example response:
"title": "test",
"file_name": "add.rb",
"description": "description of snippet",
"visibility": "internal",
"author": {
"id": 1,
"username": "john_smith",
......@@ -238,7 +241,8 @@ Example response:
"raw_url": "http://localhost:3000/snippets/48/raw",
"title": "Minus similique nesciunt vel fugiat qui ullam sunt.",
"updated_at": "2016-11-25T16:53:34.479Z",
"web_url": "http://localhost:3000/snippets/48"
"web_url": "http://localhost:3000/snippets/48",
"visibility": "public"
}
]
```
......
......@@ -45,6 +45,8 @@ the `author` field. GitLab team members **should not**.
a changelog entry regardless of these guidelines if the contributor wants one.
Example: "Fixed a typo on the search results page. (Jane Smith)"
- Performance improvements **should** have a changelog entry.
- Any change that introduces a database migration **must** have a
changelog entry.
## Writing good changelog entries
......
# Testing
# Overview of Frontend Testing
> TODO: Add content
## Types of tests in our codebase
* **RSpec**
* **[Ruby unit tests](#ruby-unit-tests-spec-rb)** for models, controllers, helpers, etc. (`/spec/**/*.rb`)
* **[Full feature tests](#full-feature-tests-spec-features-rb)** (`/spec/features/**/*.rb`)
* **[Karma](#karma-tests-spec-javascripts-js)** (`/spec/javascripts/**/*.js`)
* ~~Spinach~~ — These have been removed from our codebase in May 2018. (`/features/`)
## RSpec: Ruby unit tests `/spec/**/*.rb`
These tests are meant to unit test the ruby models, controllers and helpers.
### When do we write/update these tests?
Whenever we create or modify any Ruby models, controllers or helpers we add/update corresponding tests.
---
## RSpec: Full feature tests `/spec/features/**/*.rb`
Full feature tests will load a full app environment and allow us to test things like rendering DOM, interacting with links and buttons, testing the outcome of those interactions through multiple pages if necessary. These are also called end-to-end tests but should not be confused with QA end-to-end tests (`package-and-qa` manual pipeline job).
### When do we write/update these tests?
When we add a new feature, we write at least two tests covering the success and the failure scenarios.
### Relevant notes
A `:js` flag is added to the test to make sure the full environment is loaded.
```
scenario 'successfully', :js do
sign_in(create(:admin))
end
```
The steps of each test are written using capybara methods ([documentation](http://www.rubydoc.info/gems/capybara/2.15.1)).
Bear in mind <abbr title="XMLHttpRequest">XHR</abbr> calls might require you to use `wait_for_requests` in between steps, like so:
```rspec
find('.form-control').native.send_keys(:enter)
wait_for_requests
expect(page).not_to have_selector('.card')
```
---
## Karma tests `/spec/javascripts/**/*.js`
These are the more frontend-focused, at the moment. They're **faster** than `rspec` and make for very quick testing of frontend components.
### When do we write/update these tests?
When we add/update a method/action/mutation to Vue or Vuex, we write karma tests to ensure the logic we wrote doesn't break. We should, however, refrain from writing tests that double-test Vue's internal features.
### Relevant notes
Karma tests are run against a virtual DOM.
To populate the DOM, we can use fixtures to fake the generation of HTML instead of having Rails do that.
Be sure to check the [best practices for karma tests](../../testing_guide/frontend_testing.html#best-practices).
### Vue and Vuex
Test as much as possible without double-testing Vue's internal features, as mentioned above.
Make sure to test computedProperties, mutations, actions. Run the action and test that the proper mutations are committed.
Also check these [notes on testing Vue components](../../fe_guide/vue.html#testing-vue-components).
#### Vuex Helper: `testAction`
We have a helper available to make testing actions easier, as per [official documentation](https://vuex.vuejs.org/en/testing.html):
```
testAction(
actions.actionName, // action
{ }, // params to be passed to action
state, // state
[
{ type: types.MUTATION},
{ type: types.MUTATION_1, payload: {}},
], // mutations committed
[
{ type: 'actionName', payload: {}},
{ type: 'actionName1', payload: {}},
] // actions dispatched
done,
);
```
Check an example in [spec/javascripts/ide/stores/actions_spec.jsspec/javascripts/ide/stores/actions_spec.js](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/spec/javascripts/ide/stores/actions_spec.js).
#### Vue Helper: `mountComponent`
To make mounting a Vue component easier and more readable, we have a few helpers available in `spec/helpers/vue_mount_component_helper`.
* `createComponentWithStore`
* `mountComponentWithStore`
Examples of usage:
```
beforeEach(() => {
vm = createComponentWithStore(Component, store);
vm.$store.state.currentBranchId = 'master';
vm.$mount();
},
```
```
beforeEach(() => {
vm = mountComponentWithStore(Component, {
el: '#dummy-element',
store,
props: { badge },
});
},
```
Don't forget to clean up:
```
afterEach(() => {
vm.$destroy();
});
```
......@@ -17,112 +17,197 @@ Depending on your operating system, you will need to use a shell of your prefere
Git is usually preinstalled on Mac and Linux.
Type the following command and then press enter:
```
```bash
git --version
```
You should receive a message that will tell you which Git version you have on your computer. If you don’t receive a "Git version" message, it means that you need to [download Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git).
You should receive a message that tells you which Git version you have on your computer. If you don’t receive a "Git version" message, it means that you need to [download Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git).
If Git doesn't automatically download, there's an option on the website to [download manually](https://git-scm.com/downloads). Then follow the steps on the installation window.
After you are finished installing, open a new shell and type "git --version" again to verify that it was correctly installed.
After you are finished installing Git, open a new shell and type `git --version` again to verify that it was correctly installed.
## Add your Git username and set your email
It is important to configure your Git username and email address as every Git commit will use this information to identify you as the author.
It is important to configure your Git username and email address, since every Git commit will use this information to identify you as the author.
On your shell, type the following command to add your username:
```
```bash
git config --global user.name "YOUR_USERNAME"
```
Then verify that you have the correct username:
```
```bash
git config --global user.name
```
To set your email address, type the following command:
```
```bash
git config --global user.email "your_email_address@example.com"
```
To verify that you entered your email correctly, type:
```
```bash
git config --global user.email
```
You'll need to do this only once as you are using the `--global` option. It tells Git to always use this information for anything you do on that system. If you want to override this with a different username or email address for specific projects, you can run the command without the `--global` option when you’re in that project.
You'll need to do this only once, since you are using the `--global` option. It tells Git to always use this information for anything you do on that system. If you want to override this with a different username or email address for specific projects, you can run the command without the `--global` option when you’re in that project.
## Check your information
To view the information that you entered, type:
```
To view the information that you entered, along with other global options, type:
```bash
git config --global --list
```
## Basic Git commands
### Go to the master branch to pull the latest changes from there
```
```bash
git checkout master
```
### Download the latest changes in the project
This is for you to work on an up-to-date copy (it is important to do every time you work on a project), while you setup tracking branches.
This is for you to work on an up-to-date copy (it is important to do this every time you start working on a project), while you set up tracking branches. You pull from remote repositories to get all the changes made by users since the last time you cloned or pulled the project. Later, you can push your local commits to the remote repositories.
```bash
git pull REMOTE NAME-OF-BRANCH
```
git pull REMOTE NAME-OF-BRANCH -u
When you first clone a repository, REMOTE is typically "origin". This is where the repository came from, and it indicates the SSH or HTTPS URL of the repository on the remote server. NAME-OF-BRANCH is usually "master", but it may be any existing branch.
### View your remote repositories
To view your remote repositories, type:
```bash
git remote -v
```
(REMOTE: origin) (NAME-OF-BRANCH: could be "master" or an existing branch)
### Create a branch
Spaces won't be recognized, so you will need to use a hyphen or underscore.
```
To create a branch, type the following (spaces won't be recognized in the branch name, so you will need to use a hyphen or underscore):
```bash
git checkout -b NAME-OF-BRANCH
```
### Work on a branch that has already been created
```
### Work on an existing branch
To switch to an existing branch, so you can work on it:
```bash
git checkout NAME-OF-BRANCH
```
### View the changes you've made
It's important to be aware of what's happening and what's the status of your changes.
```
It's important to be aware of what's happening and the status of your changes. When you add, change, or delete files/folders, Git knows about it. To check the status of your changes:
```bash
git status
```
### Add changes to commit
You'll see your changes in red when you type "git status".
### View differences
To view the differences between your local, unstaged changes and the repository versions that you cloned or pulled, type:
```bash
git diff
```
### Add and commit local changes
You'll see your local changes in red when you type `git status`. These changes may be new, modified, or deleted files/folders. Use `git add` to stage a local file/folder for committing. Then use `git commit` to commit the staged files:
```bash
git add FILE OR FOLDER
git commit -m "COMMENT TO DESCRIBE THE INTENTION OF THE COMMIT"
```
git add CHANGES IN RED
git commit -m "DESCRIBE THE INTENTION OF THE COMMIT"
### Add all changes to commit
To add and commit all local changes in one command:
```bash
git add .
git commit -m "COMMENT TO DESCRIBE THE INTENTION OF THE COMMIT"
```
NOTE: **Note:**
The `.` character typically means _all_ in Git.
### Send changes to gitlab.com
```
To push all local commits to the remote repository:
```bash
git push REMOTE NAME-OF-BRANCH
```
### Delete all changes in the Git repository, but leave unstaged things
For example, to push your local commits to the _master_ branch of the _origin_ remote:
```bash
git push origin master
```
### Delete all changes in the Git repository
To delete all local changes in the repository that have not been added to the staging area, and leave unstaged files/folders, type:
```bash
git checkout .
```
### Delete all changes in the Git repository, including untracked files
```
### Delete all untracked changes in the Git repository
```bash
git clean -f
```
### Unstage all changes that have been added to the staging area
To undo the most recent add, but not committed, files/folders:
```bash
git reset .
```
### Undo most recent commit
To undo the most recent commit, type:
```bash
git reset HEAD~1
```
This leaves the files and folders unstaged in your local repository.
CAUTION: **Warning:**
A Git commit is mostly irreversible, particularly if you already pushed it to the remote repository. Although you can undo a commit, the best option is to avoid the situation altogether.
### Merge created branch with master branch
You need to be in the created branch.
```
```bash
git checkout NAME-OF-BRANCH
git merge master
```
### Merge master branch with created branch
You need to be in the master branch.
```
```bash
git checkout master
git merge NAME-OF-BRANCH
```
......@@ -334,7 +334,11 @@ Issue Board, that is create/delete lists and drag issues around.
## Group Issue Boards
<<<<<<< HEAD
> Introduced in GitLab 10.6
=======
> Introduced in [GitLab 10.6](https://about.gitlab.com/2018/03/22/gitlab-10-6-released/#single-group-issue-board-in-core-and-free)
>>>>>>> upstream/master
Group Issue Board is analogous to project-level issue board and it is accessible at the group
navigation level. A group-level issue board allows you to view all issues from all projects in that group
......@@ -342,6 +346,7 @@ navigation level. A group-level issue board allows you to view all issues from a
boards. When updating milestones and labels for an issue through the sidebar update mechanism, again only
group-level objects are available.
<<<<<<< HEAD
## Assignee Lists **[PREMIUM]**
> Introduced in GitLab 11.0 Premium
......@@ -364,6 +369,9 @@ When dragging issues between lists, different behavior occurs depending on the s
| From Closed | Issue reopened | - | Issue reopened<br/>`B` added | Issue reopened<br/>`Bob` assigned |
| From label `A` list | `A` removed | Issue closed | `A` removed<br/>`B` added | `Bob` assigned |
| From assignee `Alice` list | `Alice` unassigned | Issue closed | `B` added | `Alice` unassigned<br/>`Bob` assigned |
=======
One group issue board per group was made available in GitLab 10.6 Core after multiple group issue boards were originally introduced in [GitLab 10.0 Premium](https://about.gitlab.com/2017/09/22/gitlab-10-0-released/#group-issue-boards).
>>>>>>> upstream/master
## Features per tier
......
......@@ -45,6 +45,10 @@ do.
| `/tableflip` | Append the comment with `(╯°□°)╯︵ ┻━┻` |
| `/shrug` | Append the comment with `¯\_(ツ)_/¯` |
| <code>/copy_metadata #issue &#124; !merge_request</code> | Copy labels and milestone from other issue or merge request |
<<<<<<< HEAD
Note: In GitLab Starter every issue can have more than one assignee, so commands `/assign`, `/unassign` and `/reassign`
support multiple assignees.
=======
| `/confidential` | Makes the issue confidential |
>>>>>>> upstream/master
......@@ -42,5 +42,26 @@ list.
An additional review mode is available when you open a merge request, which
shows you a preview of the merge request diff if you commit your changes.
## View CI job logs
> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19279) [GitLab Core][ce] 11.0.
The Web IDE can be used to quickly fix failing tests by opening the branch or
merge request in the Web IDE and opening the logs of the failed job. The status
of all jobs for the most recent pipeline and job traces for the current commit
can be accessed by clicking the **Pipelines** button in the top right.
The pipeline status is also shown at all times in the status bar in the bottom
left.
## Switching merge requests
> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19318) [GitLab Core][ce] 11.0.
Switching between your authored and assigned merge requests can be done without
leaving the Web IDE. Click the project name in the top left to open a list of
merge requests. You will need to commit or discard all your changes before
switching to a different merge request.
[ce]: https://about.gitlab.com/pricing/
[ee]: https://about.gitlab.com/pricing/
......@@ -362,7 +362,7 @@ module API
end
class Snippet < Grape::Entity
expose :id, :title, :file_name, :description
expose :id, :title, :file_name, :description, :visibility
expose :author, using: Entities::UserBasic
expose :updated_at, :created_at
expose :project_id
......@@ -418,6 +418,10 @@ module API
expose :state, :created_at, :updated_at
expose :due_date
expose :start_date
expose :web_url do |milestone, _options|
Gitlab::UrlBuilder.build(milestone)
end
end
class IssueBasic < ProjectEntity
......
......@@ -65,7 +65,7 @@ module Banzai
# We don't support IID lookups for group milestones, because IIDs can
# clash between group and project milestones.
if project.group && !params[:iid]
finder_params[:group_ids] = [project.group.id]
finder_params[:group_ids] = project.group.self_and_ancestors.select(:id)
end
MilestonesFinder.new(finder_params).find_by(params)
......
......@@ -179,6 +179,8 @@ module Gitlab
end
def list_commits_by_oid(oids)
return [] if oids.empty?
request = Gitaly::ListCommitsByOidRequest.new(repository: @gitaly_repo, oid: oids)
response = GitalyClient.call(@repository.storage, :commit_service, :list_commits_by_oid, request, timeout: GitalyClient.medium_timeout)
......
......@@ -20,7 +20,7 @@ module Gitlab
define_histogram :gitlab_sql_duration_seconds do
docstring 'SQL time'
base_labels Transaction::BASE_LABELS
buckets [0.001, 0.01, 0.1, 1.0, 10.0]
buckets [0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0]
end
def current_transaction
......
......@@ -149,7 +149,7 @@ module Gitlab
define_histogram :gitlab_transaction_duration_seconds do
docstring 'Transaction duration'
base_labels BASE_LABELS
buckets [0.001, 0.01, 0.1, 1.0, 10.0]
buckets [0.1, 0.25, 0.5, 1.0, 2.5, 5.0]
end
define_histogram :gitlab_transaction_allocated_memory_bytes do
......
......@@ -39,7 +39,7 @@ module Gitlab
content.delete!("\r")
content.gsub!(commands_regex) do
if $~[:cmd]
commands << [$~[:cmd], $~[:arg]].reject(&:blank?)
commands << [$~[:cmd].downcase, $~[:arg]].reject(&:blank?)
''
else
$~[0]
......@@ -102,14 +102,14 @@ module Gitlab
# /close
^\/
(?<cmd>#{Regexp.union(names)})
(?<cmd>#{Regexp.new(Regexp.union(names).source, Regexp::IGNORECASE)})
(?:
[ ]
(?<arg>[^\n]*)
)?
(?:\n|$)
)
}mx
}mix
end
def perform_substitutions(content, commands)
......@@ -120,7 +120,7 @@ module Gitlab
end
substitution_definitions.each do |substitution|
match_data = substitution.match(content)
match_data = substitution.match(content.downcase)
if match_data
command = [substitution.name.to_s]
command << match_data[1] unless match_data[1].empty?
......
......@@ -15,7 +15,7 @@ module Gitlab
return unless content
all_names.each do |a_name|
content.gsub!(%r{/#{a_name} ?(.*)$}, execute_block(action_block, context, '\1'))
content.gsub!(%r{/#{a_name} ?(.*)$}i, execute_block(action_block, context, '\1'))
end
content
end
......
......@@ -26,6 +26,8 @@ module Gitlab
project_snippet_url(object.project, object)
when Snippet
snippet_url(object)
when Milestone
milestone_url(object)
else
raise NotImplementedError.new("No URL builder defined for #{object.class}")
end
......
......@@ -7,13 +7,15 @@ module Gitlab
@batch_size = batch_size
@start = start
@finish = finish
fix_google_api_logger
end
# Yields a Range of IDs and a Hash of failed verifications (object => error)
def run_batches(&blk)
relation.in_batches(of: batch_size, start: start, finish: finish) do |relation| # rubocop: disable Cop/InBatches
range = relation.first.id..relation.last.id
failures = run_batch(relation)
all_relation.in_batches(of: batch_size, start: start, finish: finish) do |batch| # rubocop: disable Cop/InBatches
range = batch.first.id..batch.last.id
failures = run_batch_for(batch)
yield(range, failures)
end
......@@ -29,24 +31,56 @@ module Gitlab
private
def run_batch(relation)
relation.map { |upload| verify(upload) }.compact.to_h
def run_batch_for(batch)
batch.map { |upload| verify(upload) }.compact.to_h
end
def verify(object)
local?(object) ? verify_local(object) : verify_remote(object)
rescue => err
failure(object, err.inspect)
end
def verify_local(object)
expected = expected_checksum(object)
actual = actual_checksum(object)
raise 'Checksum missing' unless expected.present?
raise 'Checksum mismatch' unless expected == actual
return failure(object, 'Checksum missing') unless expected.present?
return failure(object, 'Checksum mismatch') unless expected == actual
success
end
# We don't calculate checksum for remote objects, so just check existence
def verify_remote(object)
return failure(object, 'Remote object does not exist') unless remote_object_exists?(object)
success
end
def success
nil
rescue => err
[object, err]
end
def failure(object, message)
[object, message]
end
# It's already set to Logger::INFO, but acts as if it is set to
# Logger::DEBUG, and this fixes it...
def fix_google_api_logger
if Object.const_defined?('Google::Apis')
Google::Apis.logger.level = Logger::INFO
end
end
# This should return an ActiveRecord::Relation suitable for calling #in_batches on
def relation
def all_relation
raise NotImplementedError.new
end
# Should return true if the object is stored locally
def local?(_object)
raise NotImplementedError.new
end
......@@ -59,6 +93,11 @@ module Gitlab
def actual_checksum(_object)
raise NotImplementedError.new
end
# Be sure to perform a hard check of the remote object (don't just check DB value)
def remote_object_exists?(object)
raise NotImplementedError.new
end
end
end
end
......@@ -13,10 +13,14 @@ module Gitlab
private
def relation
def all_relation
::Ci::JobArtifact.all
end
def local?(artifact)
artifact.local_store?
end
def expected_checksum(artifact)
artifact.file_sha256
end
......@@ -24,6 +28,10 @@ module Gitlab
def actual_checksum(artifact)
Digest::SHA256.file(artifact.file.path).hexdigest
end
def remote_object_exists?(artifact)
artifact.file.file.exists?
end
end
end
end
......@@ -11,8 +11,12 @@ module Gitlab
private
def relation
LfsObject.with_files_stored_locally
def all_relation
LfsObject.all
end
def local?(lfs_object)
lfs_object.local_store?
end
def expected_checksum(lfs_object)
......@@ -22,6 +26,10 @@ module Gitlab
def actual_checksum(lfs_object)
LfsObject.calculate_oid(lfs_object.file.path)
end
def remote_object_exists?(lfs_object)
lfs_object.file.file.exists?
end
end
end
end
......@@ -45,7 +45,7 @@ module Gitlab
return unless verbose?
failures.each do |object, error|
say " - #{verifier.describe(object)}: #{error.inspect}".color(:red)
say " - #{verifier.describe(object)}: #{error}".color(:red)
end
end
end
......
......@@ -11,8 +11,12 @@ module Gitlab
private
def relation
Upload.with_files_stored_locally
def all_relation
Upload.all
end
def local?(upload)
upload.local?
end
def expected_checksum(upload)
......@@ -22,6 +26,10 @@ module Gitlab
def actual_checksum(upload)
Upload.hexdigest(upload.absolute_path)
end
def remote_object_exists?(upload)
upload.build_uploader.file.exists?
end
end
end
end
......@@ -8,8 +8,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-06-11 09:18+0200\n"
"PO-Revision-Date: 2018-06-11 09:18+0200\n"
"POT-Creation-Date: 2018-06-12 18:57+1000\n"
"PO-Revision-Date: 2018-06-12 18:57+1000\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
......@@ -311,6 +311,9 @@ msgstr ""
msgid "Add new directory"
msgstr ""
msgid "Add reaction"
msgstr ""
msgid "Add todo"
msgstr ""
......
......@@ -102,19 +102,7 @@ module QA
def perform(&block)
visit(url)
yield if block_given?
rescue
raise if block.nil?
# RSpec examples will take care of screenshots on their own
#
unless block.binding.receiver.is_a?(RSpec::Core::ExampleGroup)
screenshot_and_save_page
end
raise
ensure
clear! if block_given?
yield.tap { clear! } if block_given?
end
##
......
......@@ -458,6 +458,8 @@ describe ApplicationController do
end
context 'for sessionless users' do
render_views
before do
sign_out user
end
......@@ -468,6 +470,14 @@ describe ApplicationController do
expect(response).to have_gitlab_http_status(403)
end
it 'renders the error message when the format was html' do
get :index,
private_token: create(:personal_access_token, user: user).token,
format: :html
expect(response.body).to have_content /accept the terms of service/i
end
it 'renders a 200 when the sessionless user accepted the terms' do
accept_terms(user)
......
......@@ -227,6 +227,42 @@ feature 'Issues > User uses quick actions', :js do
end
end
describe 'make issue confidential' do
let(:issue) { create(:issue, project: project) }
let(:original_issue) { create(:issue, project: project) }
context 'when the current user can update issues' do
it 'does not create a note, and marks the issue as confidential' do
add_note("/confidential")
expect(page).not_to have_content "/confidential"
expect(page).to have_content 'Commands applied'
expect(page).to have_content "made the issue confidential"
expect(issue.reload).to be_confidential
end
end
context 'when the current user cannot update the issue' do
let(:guest) { create(:user) }
before do
project.add_guest(guest)
gitlab_sign_out
sign_in(guest)
visit project_issue_path(project, issue)
end
it 'does not create a note, and does not mark the issue as confidential' do
add_note("/confidential")
expect(page).not_to have_content 'Commands applied'
expect(page).not_to have_content "made the issue confidential"
expect(issue.reload).not_to be_confidential
end
end
end
describe 'move the issue to another project' do
let(:issue) { create(:issue, project: project) }
......
......@@ -13,7 +13,8 @@
"created_at": { "type": "date" },
"updated_at": { "type": "date" },
"start_date": { "type": "date" },
"due_date": { "type": "date" }
"due_date": { "type": "date" },
"web_url": { "type": "string" }
},
"required": [
"id", "iid", "title", "description", "state",
......
......@@ -8,6 +8,7 @@
"title": { "type": "string" },
"file_name": { "type": ["string", "null"] },
"description": { "type": ["string", "null"] },
"visibility": { "type": "string" },
"web_url": { "type": "string" },
"created_at": { "type": "date" },
"updated_at": { "type": "date" },
......
require 'spec_helper'
require "spec_helper"
describe StorageHelper do
describe '#storage_counter' do
it 'formats bytes to one decimal place' do
expect(helper.storage_counter(1.23.megabytes)).to eq '1.2 MB'
describe "#storage_counter" do
it "formats bytes to one decimal place" do
expect(helper.storage_counter(1.23.megabytes)).to eq("1.2 MB")
end
it 'does not add decimals for sizes < 1 MB' do
expect(helper.storage_counter(23.5.kilobytes)).to eq '24 KB'
it "does not add decimals for sizes < 1 MB" do
expect(helper.storage_counter(23.5.kilobytes)).to eq("24 KB")
end
it 'does not add decimals for zeroes' do
expect(helper.storage_counter(2.megabytes)).to eq '2 MB'
it "does not add decimals for zeroes" do
expect(helper.storage_counter(2.megabytes)).to eq("2 MB")
end
it 'uses commas as thousands separator' do
expect(helper.storage_counter(100_000_000_000_000_000)).to eq '90,949.5 TB'
it "uses commas as thousands separator" do
if Gitlab.rails5?
expect(helper.storage_counter(100_000_000_000_000_000_000_000)).to eq("86,736.2 EB")
else
expect(helper.storage_counter(100_000_000_000_000_000)).to eq("90,949.5 TB")
end
end
end
end
......@@ -19,6 +19,7 @@ describe('Multi-file editor commit sidebar list item', () => {
vm = createComponentWithStore(Component, store, {
file: f,
actionComponent: 'stage-button',
activeFileKey: `staged-${f.key}`,
}).$mount();
});
......@@ -89,4 +90,20 @@ describe('Multi-file editor commit sidebar list item', () => {
});
});
});
describe('is active', () => {
it('does not add active class when dont keys match', () => {
expect(vm.$el.classList).not.toContain('is-active');
});
it('adds active class when keys match', done => {
vm.keyPrefix = 'staged';
vm.$nextTick(() => {
expect(vm.$el.classList).toContain('is-active');
done();
});
});
});
});
......@@ -17,6 +17,8 @@ describe('Multi-file editor commit sidebar list', () => {
action: 'stageAllChanges',
actionBtnText: 'stage all',
itemActionComponent: 'stage-button',
activeFileKey: 'staged-testing',
keyPrefix: 'staged',
});
vm.$store.state.rightPanelCollapsed = false;
......
......@@ -56,7 +56,7 @@ describe('RepoCommitSection', () => {
vm.$store.state.entries[f.path] = f;
});
return vm.$mount();
return vm;
}
beforeEach(done => {
......@@ -64,6 +64,10 @@ describe('RepoCommitSection', () => {
vm = createComponent();
spyOn(vm, 'openPendingTab').and.callThrough();
vm.$mount();
spyOn(service, 'getTreeData').and.returnValue(
Promise.resolve({
headers: {
......@@ -98,6 +102,7 @@ describe('RepoCommitSection', () => {
store.state.noChangesStateSvgPath = 'nochangessvg';
store.state.committedStateSvgPath = 'svg';
vm.$destroy();
vm = createComponentWithStore(Component, store).$mount();
expect(vm.$el.querySelector('.js-empty-state').textContent.trim()).toContain('No changes');
......@@ -176,5 +181,12 @@ describe('RepoCommitSection', () => {
expect(store.state.openFiles.length).toBe(1);
expect(store.state.openFiles[0].pending).toBe(true);
});
it('calls openPendingTab', () => {
expect(vm.openPendingTab).toHaveBeenCalledWith({
file: vm.lastOpenedFile,
keyPrefix: 'unstaged',
});
});
});
});
......@@ -152,6 +152,53 @@ describe('IDE store file mutations', () => {
expect(localFile.mrChange.diff).toBe('ABC');
});
it('has diffMode replaced by default', () => {
mutations.SET_FILE_MERGE_REQUEST_CHANGE(localState, {
file: localFile,
mrChange: {
diff: 'ABC',
},
});
expect(localFile.mrChange.diffMode).toBe('replaced');
});
it('has diffMode new', () => {
mutations.SET_FILE_MERGE_REQUEST_CHANGE(localState, {
file: localFile,
mrChange: {
diff: 'ABC',
new_file: true,
},
});
expect(localFile.mrChange.diffMode).toBe('new');
});
it('has diffMode deleted', () => {
mutations.SET_FILE_MERGE_REQUEST_CHANGE(localState, {
file: localFile,
mrChange: {
diff: 'ABC',
deleted_file: true,
},
});
expect(localFile.mrChange.diffMode).toBe('deleted');
});
it('has diffMode renamed', () => {
mutations.SET_FILE_MERGE_REQUEST_CHANGE(localState, {
file: localFile,
mrChange: {
diff: 'ABC',
renamed_file: true,
},
});
expect(localFile.mrChange.diffMode).toBe('renamed');
});
});
describe('DISCARD_FILE_CHANGES', () => {
......
......@@ -2,3 +2,6 @@ export const FIXTURES_PATH = '/base/spec/javascripts/fixtures';
export const TEST_HOST = 'http://test.host';
export const DUMMY_IMAGE_URL = `${FIXTURES_PATH}/one_white_pixel.png`;
export const GREEN_BOX_IMAGE_URL = `${FIXTURES_PATH}/images/green_box.png`;
export const RED_BOX_IMAGE_URL = `${FIXTURES_PATH}/images/red_box.png`;
......@@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import contentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { GREEN_BOX_IMAGE_URL } from 'spec/test_constants';
describe('ContentViewer', () => {
let vm;
......@@ -41,12 +42,12 @@ describe('ContentViewer', () => {
it('renders image preview', done => {
createComponent({
path: 'test.jpg',
path: GREEN_BOX_IMAGE_URL,
fileSize: 1024,
});
setTimeout(() => {
expect(vm.$el.querySelector('.image_file img').getAttribute('src')).toBe('test.jpg');
expect(vm.$el.querySelector('.image_file img').getAttribute('src')).toBe(GREEN_BOX_IMAGE_URL);
done();
});
......@@ -59,9 +60,8 @@ describe('ContentViewer', () => {
});
setTimeout(() => {
expect(vm.$el.querySelector('.file-info').textContent.trim()).toContain(
'test.abc (1.00 KiB)',
);
expect(vm.$el.querySelector('.file-info').textContent.trim()).toContain('test.abc');
expect(vm.$el.querySelector('.file-info').textContent.trim()).toContain('(1.00 KiB)');
expect(vm.$el.querySelector('.btn.btn-default').textContent.trim()).toContain('Download');
done();
......
import Vue from 'vue';
import diffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants';
describe('DiffViewer', () => {
let vm;
function createComponent(props) {
const DiffViewer = Vue.extend(diffViewer);
vm = mountComponent(DiffViewer, props);
}
afterEach(() => {
vm.$destroy();
});
it('renders image diff', done => {
window.gon = {
relative_url_root: '',
};
createComponent({
diffMode: 'replaced',
newPath: GREEN_BOX_IMAGE_URL,
newSha: 'ABC',
oldPath: RED_BOX_IMAGE_URL,
oldSha: 'DEF',
projectPath: '',
});
setTimeout(() => {
expect(vm.$el.querySelector('.deleted .image_file img').getAttribute('src')).toBe(
`//raw/DEF/${RED_BOX_IMAGE_URL}`,
);
expect(vm.$el.querySelector('.added .image_file img').getAttribute('src')).toBe(
`//raw/ABC/${GREEN_BOX_IMAGE_URL}`,
);
done();
});
});
it('renders fallback download diff display', done => {
createComponent({
diffMode: 'replaced',
newPath: 'test.abc',
newSha: 'ABC',
oldPath: 'testold.abc',
oldSha: 'DEF',
});
setTimeout(() => {
expect(vm.$el.querySelector('.deleted .file-info').textContent.trim()).toContain(
'testold.abc',
);
expect(vm.$el.querySelector('.deleted .btn.btn-default').textContent.trim()).toContain(
'Download',
);
expect(vm.$el.querySelector('.added .file-info').textContent.trim()).toContain('test.abc');
expect(vm.$el.querySelector('.added .btn.btn-default').textContent.trim()).toContain(
'Download',
);
done();
});
});
});
import Vue from 'vue';
import imageDiffViewer from '~/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants';
describe('ImageDiffViewer', () => {
let vm;
function createComponent(props) {
const ImageDiffViewer = Vue.extend(imageDiffViewer);
vm = mountComponent(ImageDiffViewer, props);
}
const triggerEvent = (eventName, el = vm.$el, clientX = 0) => {
const event = document.createEvent('MouseEvents');
event.initMouseEvent(
eventName,
true,
true,
window,
1,
clientX,
0,
clientX,
0,
false,
false,
false,
false,
0,
null,
);
el.dispatchEvent(event);
};
const dragSlider = (sliderElement, dragPixel = 20) => {
triggerEvent('mousedown', sliderElement);
triggerEvent('mousemove', document.body, dragPixel);
triggerEvent('mouseup', document.body);
};
afterEach(() => {
vm.$destroy();
});
it('renders image diff for replaced', done => {
createComponent({
diffMode: 'replaced',
newPath: GREEN_BOX_IMAGE_URL,
oldPath: RED_BOX_IMAGE_URL,
});
setTimeout(() => {
expect(vm.$el.querySelector('.added .image_file img').getAttribute('src')).toBe(
GREEN_BOX_IMAGE_URL,
);
expect(vm.$el.querySelector('.deleted .image_file img').getAttribute('src')).toBe(
RED_BOX_IMAGE_URL,
);
expect(vm.$el.querySelector('.view-modes-menu li.active').textContent.trim()).toBe('2-up');
expect(vm.$el.querySelector('.view-modes-menu li:nth-child(2)').textContent.trim()).toBe(
'Swipe',
);
expect(vm.$el.querySelector('.view-modes-menu li:nth-child(3)').textContent.trim()).toBe(
'Onion skin',
);
done();
});
});
it('renders image diff for new', done => {
createComponent({
diffMode: 'new',
newPath: GREEN_BOX_IMAGE_URL,
oldPath: '',
});
setTimeout(() => {
expect(vm.$el.querySelector('.added .image_file img').getAttribute('src')).toBe(
GREEN_BOX_IMAGE_URL,
);
done();
});
});
it('renders image diff for deleted', done => {
createComponent({
diffMode: 'deleted',
newPath: '',
oldPath: RED_BOX_IMAGE_URL,
});
setTimeout(() => {
expect(vm.$el.querySelector('.deleted .image_file img').getAttribute('src')).toBe(
RED_BOX_IMAGE_URL,
);
done();
});
});
describe('swipeMode', () => {
beforeEach(done => {
createComponent({
diffMode: 'replaced',
newPath: GREEN_BOX_IMAGE_URL,
oldPath: RED_BOX_IMAGE_URL,
});
setTimeout(() => {
done();
});
});
it('switches to Swipe Mode', done => {
vm.$el.querySelector('.view-modes-menu li:nth-child(2)').click();
vm.$nextTick(() => {
expect(vm.$el.querySelector('.view-modes-menu li.active').textContent.trim()).toBe('Swipe');
done();
});
});
it('drag handler is working', done => {
vm.$el.querySelector('.view-modes-menu li:nth-child(2)').click();
vm.$nextTick(() => {
expect(vm.$el.querySelector('.swipe-bar').style.left).toBe('1px');
expect(vm.$el.querySelector('.top-handle')).not.toBeNull();
dragSlider(vm.$el.querySelector('.swipe-bar'), 40);
vm.$nextTick(() => {
expect(vm.$el.querySelector('.swipe-bar').style.left).toBe('-20px');
done();
});
});
});
});
describe('onionSkin', () => {
beforeEach(done => {
createComponent({
diffMode: 'replaced',
newPath: GREEN_BOX_IMAGE_URL,
oldPath: RED_BOX_IMAGE_URL,
});
setTimeout(() => {
done();
});
});
it('switches to Onion Skin Mode', done => {
vm.$el.querySelector('.view-modes-menu li:nth-child(3)').click();
vm.$nextTick(() => {
expect(vm.$el.querySelector('.view-modes-menu li.active').textContent.trim()).toBe(
'Onion skin',
);
done();
});
});
it('has working drag handler', done => {
vm.$el.querySelector('.view-modes-menu li:nth-child(3)').click();
vm.$nextTick(() => {
expect(vm.$el.querySelector('.dragger').style.left).toBe('100px');
dragSlider(vm.$el.querySelector('.dragger'));
vm.$nextTick(() => {
expect(vm.$el.querySelector('.dragger').style.left).toBe('20px');
expect(vm.$el.querySelector('.added.frame').style.opacity).toBe('0.2');
done();
});
});
});
});
});
import * as domUtils from '~/vue_shared/components/lib/utils/dom_utils';
describe('domUtils', () => {
describe('pixeliseValue', () => {
it('should add px to a given Number', () => {
expect(domUtils.pixeliseValue(12)).toEqual('12px');
});
it('should not add px to 0', () => {
expect(domUtils.pixeliseValue(0)).toEqual('');
});
});
});
......@@ -3,7 +3,8 @@ require 'spec_helper'
describe Banzai::Filter::MilestoneReferenceFilter do
include FilterSpecHelper
let(:group) { create(:group, :public) }
let(:parent_group) { create(:group, :public) }
let(:group) { create(:group, :public, parent: parent_group) }
let(:project) { create(:project, :public, group: group) }
it 'requires project context' do
......@@ -340,6 +341,13 @@ describe Banzai::Filter::MilestoneReferenceFilter do
expect(doc.css('a')).to be_empty
end
it 'supports parent group references', :nested_groups do
milestone.update!(group: parent_group)
doc = reference_filter("See #{reference}")
expect(doc.css('a').first.text).to eq(milestone.name)
end
end
context 'group context' do
......
......@@ -421,6 +421,16 @@ describe Gitlab::Git::Commit, seed_helper: true do
end
end
describe '#batch_by_oid' do
context 'when oids is empty' do
it 'makes no Gitaly request' do
expect(Gitlab::GitalyClient).not_to receive(:call)
described_class.batch_by_oid(repository, [])
end
end
end
shared_examples 'extracting commit signature' do
context 'when the commit is signed' do
let(:commit_id) { '0b4bc9a49b562e85de7cc9e834518ea6828729b9' }
......
......@@ -182,6 +182,14 @@ describe Gitlab::QuickActions::Extractor do
expect(msg).to eq "hello\nworld"
end
it 'extracts command case insensitive' do
msg = %(hello\n/PoWer @user.name %9.10 ~"bar baz.2"\nworld)
msg, commands = extractor.extract_commands(msg)
expect(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2"']]
expect(msg).to eq "hello\nworld"
end
it 'does not extract noop commands' do
msg = %(hello\nworld\n/reopen\n/noop_command)
msg, commands = extractor.extract_commands(msg)
......@@ -206,6 +214,14 @@ describe Gitlab::QuickActions::Extractor do
expect(msg).to eq "hello\nworld\nthis is great? SHRUG"
end
it 'extracts and performs substitution commands case insensitive' do
msg = %(hello\nworld\n/reOpen\n/sHRuG this is great?)
msg, commands = extractor.extract_commands(msg)
expect(commands).to eq [['reopen'], ['shrug', 'this is great?']]
expect(msg).to eq "hello\nworld\nthis is great? SHRUG"
end
it 'extracts and performs substitution commands with comments' do
msg = %(hello\nworld\n/reopen\n/substitution wow this is a thing.)
msg, commands = extractor.extract_commands(msg)
......
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