Commit 26137130 authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch 'master' into fl-eslint-vue-house-keeping

* master: (27 commits)
  Resolve "Bug: When creating an account with invalid characters the error is "Username already taken" not "Invalid characters used""
  Resolve "Provide ability to retrieve `visibility` level via Snippets API"
  Resolve "Quick actions are case sensitive"
  Image Diff Viewing + Download Diff Viewing
  Link to release post for group issue board docs
  moved strings into constants file
  Typo fix
  fixed condition check
  Loosen the 'newly created MR' matcher
  Ensure we look into the correct setion only when expanding a settings' section
  Rails5 fix expected `issuable.reload.updated_at` to have changed
  [Rails5] Pass class references instead of strings to middleware builder
  Sidebar Milestone - Fix wrong URL when selecting a parent group milestone
  Fix Banzai reference for milestones belonging to parent groups
  apply feedback
  fixed eslint
  added specs for is-active class added spec for openPendingTab in component
  more karma fixes
  karma updates
  Override exclusive_lease_key method in RecordsUpload
  ...
parents 1397db0f f23dbfd6
......@@ -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="multi-file-commit-list-item"
:class="{
'is-active': isActive
}"
>
<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,21 +91,25 @@ export default {
>
<commit-files-list
:title="__('Unstaged')"
:key-prefix="$options.stageKeys.unstaged"
:file-list="changedFiles"
:action-btn-text="__('Stage all')"
class="is-first"
icon-name="unstaged"
action="stageAllChanges"
item-action-component="stage-button"
:active-file-key="activeFileKey"
/>
<commit-files-list
:title="__('Staged')"
:key-prefix="$options.stageKeys.staged"
:file-list="stagedFiles"
:action-btn-text="__('Unstage all')"
:staged-list="true"
icon-name="staged"
action="unstageAllChanges"
item-action-component="unstage-button"
:active-file-key="activeFileKey"
/>
</template>
<empty-state
......
......@@ -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,
......
......@@ -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;
......
......@@ -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
class="diff-file preview-container"
v-if="viewer">
<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
class="onion-skin-frame"
:style="{
'width': onionMaxPixelWidth,
'height': onionMaxPixelHeight,
'user-select': dragging === true ? 'none' : '',
}">
<div
class="frame deleted"
:style="{
'width': onionMaxPixelWidth,
'height': onionMaxPixelHeight,
}">
<image-viewer
key="onionOldImg"
:render-info="false"
:path="oldPath"
:project-path="projectPath"
@imgLoaded="onionOldImgLoaded"
/>
</div>
<div
class="added frame"
ref="addedFrame"
:style="{
'opacity': onionOpacity,
'width': onionMaxPixelWidth,
'height': onionMaxPixelHeight,
}">
<image-viewer
key="onionNewImg"
:render-info="false"
:path="newPath"
:project-path="projectPath"
@imgLoaded="onionNewImgLoaded"
/>
</div>
<div class="controls">
<div class="transparent"></div>
<div
class="drag-track"
ref="dragTrack"
@mousedown="startDrag"
@mouseup="stopDrag">
<div
class="dragger"
ref="dragger"
:style="{ 'left': onionDraggerPixelPos }">
</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
class="swipe-frame"
ref="swipeFrame"
:style="{
'width': swipeMaxPixelWidth,
'height': swipeMaxPixelHeight,
}">
<div class="frame deleted">
<image-viewer
key="swipeOldImg"
ref="swipeOldImg"
:render-info="false"
:path="oldPath"
:project-path="projectPath"
@imgLoaded="swipeOldImgLoaded"
/>
</div>
<div
class="swipe-wrap"
ref="swipeWrap"
:style="{
'width': swipeWrapPixelWidth,
'height': swipeMaxPixelHeight,
}">
<div class="frame added">
<image-viewer
key="swipeNewImg"
:render-info="false"
:path="newPath"
:project-path="projectPath"
@imgLoaded="swipeNewImgLoaded"
/>
</div>
</div>
<span
class="swipe-bar"
ref="swipeBar"
@mousedown="startDrag"
@mouseup="stopDrag"
:style="{ 'left': swipeBarPixelPos }">
<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
class="diff-viewer"
v-if="diffMode === $options.diffModes.replaced">
<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 {};
......@@ -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;
}
}
......@@ -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;
}
......
......@@ -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;
......
......@@ -19,4 +19,9 @@ class Timelog < ActiveRecord::Base
errors.add(:base, 'Issue or Merge Request ID is required')
end
end
# Rails5 defaults to :touch_later, overwrite for normal touch
def belongs_to_touch_method
:touch
end
end
......@@ -65,10 +65,10 @@ class FileUploader < GitlabUploader
SecureRandom.hex
end
def upload_paths(filename)
def upload_paths(identifier)
[
File.join(secret, filename),
File.join(base_dir(Store::REMOTE), secret, filename)
File.join(secret, identifier),
File.join(base_dir(Store::REMOTE), secret, identifier)
]
end
......
......@@ -10,6 +10,17 @@ module ObjectStorage
UnknownStoreError = Class.new(StandardError)
ObjectStorageUnavailable = Class.new(StandardError)
class ExclusiveLeaseTaken < StandardError
def initialize(lease_key)
@lease_key = lease_key
end
def message
*lease_key_group, _ = *@lease_key.split(":")
"Exclusive lease for #{lease_key_group.join(':')} is already taken."
end
end
TMP_UPLOAD_PATH = 'tmp/uploads'.freeze
module Store
......@@ -29,7 +40,7 @@ module ObjectStorage
end
def retrieve_from_store!(identifier)
paths = store_dirs.map { |store, path| File.join(path, identifier) }
paths = upload_paths(identifier)
unless current_upload_satisfies?(paths, model)
# the upload we already have isn't right, find the correct one
......@@ -62,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)
......@@ -261,7 +281,7 @@ module ObjectStorage
end
def delete_migrated_file(migrated_file)
migrated_file.delete if exists?
migrated_file.delete
end
def exists?
......@@ -279,6 +299,13 @@ module ObjectStorage
}
end
# Returns all the possible paths for an upload.
# the `upload.path` is a lookup parameter, and it may change
# depending on the `store` param.
def upload_paths(identifier)
store_dirs.map { |store, path| File.join(path, identifier) }
end
def cache!(new_file = sanitized_file)
# We intercept ::UploadedFile which might be stored on remote storage
# We use that for "accelerated" uploads, where we store result on remote storage
......@@ -298,6 +325,10 @@ module ObjectStorage
super
end
def exclusive_lease_key
"object_storage_migrate:#{model.class}:#{model.id}"
end
private
def schedule_background_upload?
......@@ -364,17 +395,14 @@ module ObjectStorage
end
end
def exclusive_lease_key
"object_storage_migrate:#{model.class}:#{model.id}"
end
def with_exclusive_lease
uuid = Gitlab::ExclusiveLease.new(exclusive_lease_key, timeout: 1.hour.to_i).try_obtain
raise 'exclusive lease already taken' unless uuid
lease_key = exclusive_lease_key
uuid = Gitlab::ExclusiveLease.new(lease_key, timeout: 1.hour.to_i).try_obtain
raise ExclusiveLeaseTaken.new(lease_key) unless uuid
yield uuid
ensure
Gitlab::ExclusiveLease.cancel(exclusive_lease_key, uuid)
Gitlab::ExclusiveLease.cancel(lease_key, uuid)
end
#
......
......@@ -22,7 +22,7 @@ module RecordsUploads
Upload.transaction do
uploads.where(path: upload_path).delete_all
upload.destroy! if upload
upload.delete if upload
self.upload = build_upload.tap(&:save!)
end
......
......@@ -169,7 +169,7 @@
.settings-content
= render 'logging'
%section.settings.as-repository-storage.no-animate#js-repository-storage-settings{ class: ('expanded' if expanded) }
%section.qa-repository-storage-settings.settings.as-repository-storage.no-animate#js-repository-storage-settings{ class: ('expanded' if expanded) }
.settings-header
%h4
= _('Repository storage')
......
- expanded = Rails.env.test?
%section.settings.no-animate{ class: ('expanded' if expanded) }
%section.qa-deploy-keys-settings.settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
Deploy Keys
......
......@@ -82,7 +82,7 @@
= render_if_exists 'projects/issues_settings'
%section.settings.merge-requests-feature.no-animate{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)] }
%section.qa-merge-request-settings.settings.merge-requests-feature.no-animate{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)] }
.settings-header
%h4
Merge request
......@@ -101,7 +101,7 @@
= render 'export', project: @project
%section.settings.advanced-settings.no-animate{ class: ('expanded' if expanded) }
%section.qa-advanced-settings.settings.advanced-settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
Advanced
......
- expanded = Rails.env.test?
%section.settings.no-animate{ class: ('expanded' if expanded) }
%section.qa-protected-branches-settings.settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
Protected Branches
......
......@@ -16,7 +16,7 @@
.settings-content
= render 'form'
%section.settings#autodevops-settings.no-animate{ class: ('expanded' if expanded) }
%section.qa-autodevops-settings.settings#autodevops-settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
= s_('CICD|Auto DevOps')
......@@ -28,7 +28,7 @@
.settings-content
= render 'autodevops_form'
%section.settings.no-animate{ class: ('expanded' if expanded) }
%section.qa-runners-settings.settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
Runners
......@@ -39,7 +39,7 @@
.settings-content
= render 'projects/runners/index'
%section.settings.no-animate{ class: ('expanded' if expanded) }
%section.qa-variables-settings.settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
= _('Variables')
......
......@@ -8,7 +8,7 @@
%h4
= s_('WikiEmpty|The wiki lets you write documentation for your project')
%p.text-left
= s_("WikiEmpty|A wiki is where you can store all the details about your project. This can include why you've created it, it's principles, how to use it, and so on.")
= s_("WikiEmpty|A wiki is where you can store all the details about your project. This can include why you've created it, its principles, how to use it, and so on.")
= create_link
- elsif can?(current_user, :read_issue, @project)
......
---
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: Optimize the upload migration proces
merge_request: 15947
author:
type: fixed
---
title: Use upload ID for creating lease key for file uploaders.
merge_request:
author:
type: fixed
---
title: Rails5 fix expected `issuable.reload.updated_at` to have changed
merge_request: 19733
author: Jasper Maes
type: fixed
---
title: Web IDE supports now Image + Download Diff Viewing
merge_request: 18768
author:
type: added
......@@ -12,6 +12,7 @@ module Gitlab
require_dependency Rails.root.join('lib/gitlab/redis/shared_state')
require_dependency Rails.root.join('lib/gitlab/request_context')
require_dependency Rails.root.join('lib/gitlab/current_settings')
require_dependency Rails.root.join('lib/gitlab/middleware/read_only')
# Settings in config/environments/* take precedence over those specified here.
# Application configuration should go into files in config/initializers
......@@ -175,7 +176,7 @@ module Gitlab
ENV['GIT_TERMINAL_PROMPT'] = '0'
# Gitlab Read-only middleware support
config.middleware.insert_after ActionDispatch::Flash, '::Gitlab::Middleware::ReadOnly'
config.middleware.insert_after ActionDispatch::Flash, ::Gitlab::Middleware::ReadOnly
config.generators do |g|
g.factory_bot false
......
Rails.application.configure do
# Make sure the middleware is inserted first in middleware chain
config.middleware.insert_before('ActionDispatch::Static', 'Gitlab::Testing::RequestBlockerMiddleware')
config.middleware.insert_before('ActionDispatch::Static', 'Gitlab::Testing::RequestInspectorMiddleware')
config.middleware.insert_before(ActionDispatch::Static, Gitlab::Testing::RequestBlockerMiddleware)
config.middleware.insert_before(ActionDispatch::Static, Gitlab::Testing::RequestInspectorMiddleware)
# Settings specified here will take precedence over those in config/application.rb
......
......@@ -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"
}
]
```
......
......@@ -237,13 +237,15 @@ Issue Board, that is create/delete lists and drag issues around.
## Group Issue Board
>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)
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 or descendant subgroups. Similarly, you can only filter by group labels for these
boards. When updating milestones and labels for an issue through the sidebar update mechanism, again only
group-level objects are available.
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).
## Features per tier
Different issue board features are available in different [GitLab tiers](https://about.gitlab.com/pricing/), as shown in the following table:
......
......@@ -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
......@@ -416,6 +416,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)
......
......@@ -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
......
......@@ -4734,7 +4734,7 @@ msgstr ""
msgid "WikiEmptyIssueMessage|issue tracker"
msgstr ""
msgid "WikiEmpty|A wiki is where you can store all the details about your project. This can include why you've created it, it's principles, how to use it, and so on."
msgid "WikiEmpty|A wiki is where you can store all the details about your project. This can include why you've created it, its principles, how to use it, and so on."
msgstr ""
msgid "WikiEmpty|Create your first page"
......
......@@ -6,11 +6,11 @@ module QA
include QA::Page::Settings::Common
view 'app/views/admin/application_settings/show.html.haml' do
element :advanced_settings_section, 'Repository storage'
element :repository_storage_settings
end
def expand_repository_storage(&block)
expand_section('Repository storage') do
expand_section(:repository_storage_settings) do
RepositoryStorage.perform(&block)
end
end
......
......@@ -4,9 +4,9 @@ module QA
module Settings
class Advanced < Page::Base
view 'app/views/projects/edit.html.haml' do
element :project_path_field, 'f.text_field :path'
element :project_name_field, 'f.text_field :name'
element :rename_project_button, "f.submit 'Rename project'"
element :project_path_field, 'text_field :path'
element :project_name_field, 'text_field :name'
element :rename_project_button, "submit 'Rename project'"
end
def rename_to(path)
......
......@@ -6,31 +6,33 @@ module QA # rubocop:disable Naming/FileName
include Common
view 'app/views/projects/settings/ci_cd/show.html.haml' do
element :runners_settings, 'Runners'
element :secret_variables, 'Variables'
element :auto_devops_section, 'Auto DevOps'
element :autodevops_settings
element :runners_settings
element :variables_settings
end
view 'app/views/projects/settings/ci_cd/_autodevops_form.html.haml' do
element :enable_auto_devops_button, 'Enable Auto DevOps'
element :domain_input, 'Domain'
element :enable_auto_devops_field, 'radio_button :enabled'
element :domain_field, 'text_field :domain'
element :enable_auto_devops_button, "%strong= s_('CICD|Enable Auto DevOps')"
element :domain_input, "%strong= _('Domain')"
element :save_changes_button, "submit 'Save changes'"
end
def expand_runners_settings(&block)
expand_section('Runners') do
expand_section(:runners_settings) do
Settings::Runners.perform(&block)
end
end
def expand_secret_variables(&block)
expand_section('Variables') do
expand_section(:variables_settings) do
Settings::SecretVariables.perform(&block)
end
end
def enable_auto_devops_with_domain(domain)
expand_section('Auto DevOps') do
expand_section(:autodevops_settings) do
choose 'Enable Auto DevOps'
fill_in 'Domain', with: domain
click_on 'Save changes'
......
......@@ -6,11 +6,11 @@ module QA
include Common
view 'app/views/projects/edit.html.haml' do
element :advanced_settings_section, 'Advanced'
element :advanced_settings
end
def expand_advanced_settings(&block)
expand_section('Advanced settings') do
expand_section(:advanced_settings) do
Advanced.perform(&block)
end
end
......
......@@ -5,17 +5,17 @@ module QA
class MergeRequest < QA::Page::Base
include Common
view 'app/views/projects/_merge_request_merge_method_settings.html.haml' do
element :radio_button_merge_ff
end
view 'app/views/projects/edit.html.haml' do
element :merge_request_settings, 'Merge request'
element :merge_request_settings
element :save_merge_request_changes
end
view 'app/views/projects/_merge_request_merge_method_settings.html.haml' do
element :radio_button_merge_ff
end
def enable_ff_only
expand_section('Merge request') do
expand_section(:merge_request_settings) do
click_element :radio_button_merge_ff
click_element :save_merge_request_changes
end
......
......@@ -6,17 +6,21 @@ module QA
include Common
view 'app/views/projects/deploy_keys/_index.html.haml' do
element :deploy_keys_section, 'Deploy Keys'
element :deploy_keys_settings
end
view 'app/views/projects/protected_branches/shared/_index.html.haml' do
element :protected_branches_settings
end
def expand_deploy_keys(&block)
expand_section('Deploy Keys') do
expand_section(:deploy_keys_settings) do
DeployKeys.perform(&block)
end
end
def expand_protected_branches(&block)
expand_section('Protected Branches') do
expand_section(:protected_branches_settings) do
ProtectedBranches.perform(&block)
end
end
......
......@@ -4,19 +4,17 @@ module QA
module Common
# Click the Expand button present in the specified section
#
# @param [String] name present in the container in the DOM
def expand_section(name)
page.within('#content-body') do
page.within('section', text: name) do
# Because it is possible to click the button before the JS toggle code is bound
wait(reload: false) do
click_button 'Expand' unless first('button', text: 'Collapse')
# @param [Symbol] and `element` name defined in a `view` block
def expand_section(element_name)
within_element(element_name) do
# Because it is possible to click the button before the JS toggle code is bound
wait(reload: false) do
click_button 'Expand' unless first('button', text: 'Collapse')
page.has_content?('Collapse')
end
yield if block_given?
page.has_content?('Collapse')
end
yield if block_given?
end
end
end
......
......@@ -11,7 +11,7 @@ module QA
expect(page).to have_content('This is a merge request')
expect(page).to have_content('Great feature')
expect(page).to have_content(/Opened [\w\s]+ a minute ago/)
expect(page).to have_content(/Opened [\w\s]+ ago/)
end
end
end
......@@ -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" },
......
......@@ -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
......
......@@ -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)
......
......@@ -22,6 +22,31 @@ describe Gitlab::UrlBuilder do
end
end
context 'when passing a Milestone' do
let(:group) { create(:group) }
let(:project) { create(:project, :public, namespace: group) }
context 'belonging to a project' do
it 'returns a proper URL' do
milestone = create(:milestone, project: project)
url = described_class.build(milestone)
expect(url).to eq "#{Settings.gitlab['url']}/#{milestone.project.full_path}/milestones/#{milestone.iid}"
end
end
context 'belonging to a group' do
it 'returns a proper URL' do
milestone = create(:milestone, group: group)
url = described_class.build(milestone)
expect(url).to eq "#{Settings.gitlab['url']}/groups/#{milestone.group.full_path}/-/milestones/#{milestone.iid}"
end
end
end
context 'when passing a MergeRequest' do
it 'returns a proper URL' do
merge_request = build_stubbed(:merge_request, iid: 42)
......
......@@ -20,6 +20,7 @@ describe API::Snippets do
private_snippet.id)
expect(json_response.last).to have_key('web_url')
expect(json_response.last).to have_key('raw_url')
expect(json_response.last).to have_key('visibility')
end
it 'hides private snippets from regular user' do
......@@ -112,6 +113,7 @@ describe API::Snippets do
expect(json_response['title']).to eq(snippet.title)
expect(json_response['description']).to eq(snippet.description)
expect(json_response['file_name']).to eq(snippet.file_name)
expect(json_response['visibility']).to eq(snippet.visibility)
end
it 'returns 404 for invalid snippet id' do
......@@ -142,6 +144,7 @@ describe API::Snippets do
expect(json_response['title']).to eq(params[:title])
expect(json_response['description']).to eq(params[:description])
expect(json_response['file_name']).to eq(params[:file_name])
expect(json_response['visibility']).to eq(params[:visibility])
end
it 'returns 400 for missing parameters' do
......
......@@ -76,26 +76,24 @@ shared_examples "migrates" do |to_store:, from_store: nil|
end
context 'when migrate! is occupied by another process' do
let(:exclusive_lease_key) { "object_storage_migrate:#{subject.model.class}:#{subject.model.id}" }
before do
@uuid = Gitlab::ExclusiveLease.new(exclusive_lease_key, timeout: 1.hour.to_i).try_obtain
@uuid = Gitlab::ExclusiveLease.new(subject.exclusive_lease_key, timeout: 1.hour.to_i).try_obtain
end
it 'does not execute migrate!' do
expect(subject).not_to receive(:unsafe_migrate!)
expect { migrate(to) }.to raise_error('exclusive lease already taken')
expect { migrate(to) }.to raise_error(ObjectStorage::ExclusiveLeaseTaken)
end
it 'does not execute use_file' do
expect(subject).not_to receive(:unsafe_use_file)
expect { subject.use_file }.to raise_error('exclusive lease already taken')
expect { subject.use_file }.to raise_error(ObjectStorage::ExclusiveLeaseTaken)
end
after do
Gitlab::ExclusiveLease.cancel(exclusive_lease_key, @uuid)
Gitlab::ExclusiveLease.cancel(subject.exclusive_lease_key, @uuid)
end
end
......
......@@ -321,7 +321,7 @@ describe ObjectStorage do
when_file_is_in_use do
expect(uploader).not_to receive(:unsafe_migrate!)
expect { uploader.migrate!(described_class::Store::REMOTE) }.to raise_error('exclusive lease already taken')
expect { uploader.migrate!(described_class::Store::REMOTE) }.to raise_error(ObjectStorage::ExclusiveLeaseTaken)
end
end
......@@ -329,7 +329,19 @@ describe ObjectStorage do
when_file_is_in_use do
expect(uploader).not_to receive(:unsafe_use_file)
expect { uploader.use_file }.to raise_error('exclusive lease already taken')
expect { uploader.use_file }.to raise_error(ObjectStorage::ExclusiveLeaseTaken)
end
end
it 'can still migrate other files of the same model' do
uploader2 = uploader_class.new(object, :file)
uploader2.upload = create(:upload)
uploader.upload = create(:upload)
when_file_is_in_use do
expect(uploader2).to receive(:unsafe_migrate!)
uploader2.migrate!(described_class::Store::REMOTE)
end
end
end
......
......@@ -11,6 +11,12 @@ describe ObjectStorage::MigrateUploadsWorker, :sidekiq do
let(:uploads) { Upload.all }
let(:to_store) { ObjectStorage::Store::REMOTE }
def perform(uploads)
described_class.new.perform(uploads.ids, model_class.to_s, mounted_as, to_store)
rescue ObjectStorage::MigrateUploadsWorker::Report::MigrationFailures
# swallow
end
shared_examples "uploads migration worker" do
describe '.enqueue!' do
def enqueue!
......@@ -69,12 +75,6 @@ describe ObjectStorage::MigrateUploadsWorker, :sidekiq do
end
describe '#perform' do
def perform
described_class.new.perform(uploads.ids, model_class.to_s, mounted_as, to_store)
rescue ObjectStorage::MigrateUploadsWorker::Report::MigrationFailures
# swallow
end
shared_examples 'outputs correctly' do |success: 0, failures: 0|
total = success + failures
......@@ -82,7 +82,7 @@ describe ObjectStorage::MigrateUploadsWorker, :sidekiq do
it 'outputs the reports' do
expect(Rails.logger).to receive(:info).with(%r{Migrated #{success}/#{total} files})
perform
perform(uploads)
end
end
......@@ -90,7 +90,7 @@ describe ObjectStorage::MigrateUploadsWorker, :sidekiq do
it 'outputs upload failures' do
expect(Rails.logger).to receive(:warn).with(/Error .* I am a teapot/)
perform
perform(uploads)
end
end
end
......@@ -98,7 +98,7 @@ describe ObjectStorage::MigrateUploadsWorker, :sidekiq do
it_behaves_like 'outputs correctly', success: 10
it 'migrates files' do
perform
perform(uploads)
expect(Upload.where(store: ObjectStorage::Store::LOCAL).count).to eq(0)
end
......@@ -123,6 +123,17 @@ describe ObjectStorage::MigrateUploadsWorker, :sidekiq do
end
it_behaves_like "uploads migration worker"
describe "limits N+1 queries" do
it "to N*5" do
query_count = ActiveRecord::QueryRecorder.new { perform(uploads) }
more_projects = create_list(:project, 3, :with_avatar)
expected_queries_per_migration = 5 * more_projects.count
expect { perform(Upload.all) }.not_to exceed_query_limit(query_count).with_threshold(expected_queries_per_migration)
end
end
end
context "for FileUploader" do
......@@ -130,15 +141,29 @@ describe ObjectStorage::MigrateUploadsWorker, :sidekiq do
let(:secret) { SecureRandom.hex }
let(:mounted_as) { nil }
def upload_file(project)
uploader = FileUploader.new(project)
uploader.store!(fixture_file_upload('spec/fixtures/doc_sample.txt'))
end
before do
stub_uploads_object_storage(FileUploader)
projects.map do |project|
uploader = FileUploader.new(project)
uploader.store!(fixture_file_upload('spec/fixtures/doc_sample.txt'))
end
projects.map(&method(:upload_file))
end
it_behaves_like "uploads migration worker"
describe "limits N+1 queries" do
it "to N*5" do
query_count = ActiveRecord::QueryRecorder.new { perform(uploads) }
more_projects = create_list(:project, 3)
more_projects.map(&method(:upload_file))
expected_queries_per_migration = 5 * more_projects.count
expect { perform(Upload.all) }.not_to exceed_query_limit(query_count).with_threshold(expected_queries_per_migration)
end
end
end
end
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