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 { ...@@ -43,6 +43,15 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
activeFileKey: {
type: String,
required: false,
default: null,
},
keyPrefix: {
type: String,
required: true,
},
}, },
data() { data() {
return { return {
...@@ -113,8 +122,9 @@ export default { ...@@ -113,8 +122,9 @@ export default {
<list-item <list-item
:file="file" :file="file"
:action-component="itemActionComponent" :action-component="itemActionComponent"
:key-prefix="title" :key-prefix="keyPrefix"
:staged-list="stagedList" :staged-list="stagedList"
:active-file-key="activeFileKey"
/> />
</li> </li>
</ul> </ul>
......
...@@ -30,6 +30,11 @@ export default { ...@@ -30,6 +30,11 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
activeFileKey: {
type: String,
required: false,
default: null,
},
}, },
computed: { computed: {
iconName() { iconName() {
...@@ -39,6 +44,12 @@ export default { ...@@ -39,6 +44,12 @@ export default {
iconClass() { iconClass() {
return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`; 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: { methods: {
...mapActions([ ...mapActions([
...@@ -51,7 +62,7 @@ export default { ...@@ -51,7 +62,7 @@ export default {
openFileInEditor() { openFileInEditor() {
return this.openPendingTab({ return this.openPendingTab({
file: this.file, file: this.file,
keyPrefix: this.keyPrefix.toLowerCase(), keyPrefix: this.keyPrefix,
}).then(changeViewer => { }).then(changeViewer => {
if (changeViewer) { if (changeViewer) {
this.updateViewer(viewerTypes.diff); this.updateViewer(viewerTypes.diff);
...@@ -70,7 +81,12 @@ export default { ...@@ -70,7 +81,12 @@ export default {
</script> </script>
<template> <template>
<div class="multi-file-commit-list-item"> <div
class="multi-file-commit-list-item"
:class="{
'is-active': isActive
}"
>
<button <button
type="button" type="button"
class="multi-file-commit-list-path" class="multi-file-commit-list-path"
......
...@@ -6,7 +6,7 @@ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; ...@@ -6,7 +6,7 @@ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import CommitFilesList from './commit_sidebar/list.vue'; import CommitFilesList from './commit_sidebar/list.vue';
import EmptyState from './commit_sidebar/empty_state.vue'; import EmptyState from './commit_sidebar/empty_state.vue';
import * as consts from '../stores/modules/commit/constants'; import * as consts from '../stores/modules/commit/constants';
import { activityBarViews } from '../constants'; import { activityBarViews, stageKeys } from '../constants';
export default { export default {
components: { components: {
...@@ -27,11 +27,14 @@ export default { ...@@ -27,11 +27,14 @@ export default {
'unusedSeal', 'unusedSeal',
]), ]),
...mapState('commit', ['commitMessage', 'submitCommitLoading']), ...mapState('commit', ['commitMessage', 'submitCommitLoading']),
...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommitedChanges']), ...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommitedChanges', 'activeFile']),
...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled']), ...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled']),
showStageUnstageArea() { showStageUnstageArea() {
return !!(this.someUncommitedChanges || this.lastCommitMsg || !this.unusedSeal); return !!(this.someUncommitedChanges || this.lastCommitMsg || !this.unusedSeal);
}, },
activeFileKey() {
return this.activeFile ? this.activeFile.key : null;
},
}, },
watch: { watch: {
hasChanges() { hasChanges() {
...@@ -44,6 +47,7 @@ export default { ...@@ -44,6 +47,7 @@ export default {
if (this.lastOpenedFile) { if (this.lastOpenedFile) {
this.openPendingTab({ this.openPendingTab({
file: this.lastOpenedFile, file: this.lastOpenedFile,
keyPrefix: this.lastOpenedFile.changed ? stageKeys.unstaged : stageKeys.staged,
}) })
.then(changeViewer => { .then(changeViewer => {
if (changeViewer) { if (changeViewer) {
...@@ -62,6 +66,7 @@ export default { ...@@ -62,6 +66,7 @@ export default {
return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commitChanges()); return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commitChanges());
}, },
}, },
stageKeys,
}; };
</script> </script>
...@@ -86,21 +91,25 @@ export default { ...@@ -86,21 +91,25 @@ export default {
> >
<commit-files-list <commit-files-list
:title="__('Unstaged')" :title="__('Unstaged')"
:key-prefix="$options.stageKeys.unstaged"
:file-list="changedFiles" :file-list="changedFiles"
:action-btn-text="__('Stage all')" :action-btn-text="__('Stage all')"
class="is-first" class="is-first"
icon-name="unstaged" icon-name="unstaged"
action="stageAllChanges" action="stageAllChanges"
item-action-component="stage-button" item-action-component="stage-button"
:active-file-key="activeFileKey"
/> />
<commit-files-list <commit-files-list
:title="__('Staged')" :title="__('Staged')"
:key-prefix="$options.stageKeys.staged"
:file-list="stagedFiles" :file-list="stagedFiles"
:action-btn-text="__('Unstage all')" :action-btn-text="__('Unstage all')"
:staged-list="true" :staged-list="true"
icon-name="staged" icon-name="staged"
action="unstageAllChanges" action="unstageAllChanges"
item-action-component="unstage-button" item-action-component="unstage-button"
:active-file-key="activeFileKey"
/> />
</template> </template>
<empty-state <empty-state
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { mapState, mapGetters, mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex';
import flash from '~/flash'; import flash from '~/flash';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; 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 { activityBarViews, viewerTypes } from '../constants';
import Editor from '../lib/editor'; import Editor from '../lib/editor';
import ExternalLink from './external_link.vue'; import ExternalLink from './external_link.vue';
...@@ -9,6 +10,7 @@ import ExternalLink from './external_link.vue'; ...@@ -9,6 +10,7 @@ import ExternalLink from './external_link.vue';
export default { export default {
components: { components: {
ContentViewer, ContentViewer,
DiffViewer,
ExternalLink, ExternalLink,
}, },
props: { props: {
...@@ -29,9 +31,18 @@ export default { ...@@ -29,9 +31,18 @@ export default {
shouldHideEditor() { shouldHideEditor() {
return this.file && this.file.binary && !this.file.content; 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() { editTabCSS() {
return { return {
active: this.file.viewMode === 'edit', active: this.file.viewMode === 'editor',
}; };
}, },
previewTabCSS() { previewTabCSS() {
...@@ -53,7 +64,7 @@ export default { ...@@ -53,7 +64,7 @@ export default {
if (this.currentActivityView !== activityBarViews.edit) { if (this.currentActivityView !== activityBarViews.edit) {
this.setFileViewMode({ this.setFileViewMode({
file: this.file, file: this.file,
viewMode: 'edit', viewMode: 'editor',
}); });
} }
} }
...@@ -62,7 +73,7 @@ export default { ...@@ -62,7 +73,7 @@ export default {
if (this.currentActivityView !== activityBarViews.edit) { if (this.currentActivityView !== activityBarViews.edit) {
this.setFileViewMode({ this.setFileViewMode({
file: this.file, file: this.file,
viewMode: 'edit', viewMode: 'editor',
}); });
} }
}, },
...@@ -197,7 +208,7 @@ export default { ...@@ -197,7 +208,7 @@ export default {
<a <a
href="javascript:void(0);" href="javascript:void(0);"
role="button" role="button"
@click.prevent="setFileViewMode({ file, viewMode: 'edit' })"> @click.prevent="setFileViewMode({ file, viewMode: 'editor' })">
<template v-if="viewer === $options.viewerTypes.edit"> <template v-if="viewer === $options.viewerTypes.edit">
{{ __('Edit') }} {{ __('Edit') }}
</template> </template>
...@@ -222,7 +233,7 @@ export default { ...@@ -222,7 +233,7 @@ export default {
/> />
</div> </div>
<div <div
v-show="!shouldHideEditor && file.viewMode === 'edit'" v-show="!shouldHideEditor && file.viewMode ==='editor'"
ref="editor" ref="editor"
:class="{ :class="{
'is-readonly': isCommitModeActive, 'is-readonly': isCommitModeActive,
...@@ -231,10 +242,18 @@ export default { ...@@ -231,10 +242,18 @@ export default {
> >
</div> </div>
<content-viewer <content-viewer
v-if="shouldHideEditor || file.viewMode === 'preview'" v-if="showContentViewer"
:content="file.content || file.raw" :content="file.content || file.raw"
:path="file.rawPath || file.path" :path="file.rawPath || file.path"
:file-size="file.size" :file-size="file.size"
:project-path="file.projectId"/> :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> </div>
</template> </template>
...@@ -21,7 +21,19 @@ export const viewerTypes = { ...@@ -21,7 +21,19 @@ export const viewerTypes = {
diff: 'diff', diff: 'diff',
}; };
export const diffModes = {
replaced: 'replaced',
new: 'new',
deleted: 'deleted',
renamed: 'renamed',
};
export const rightSidebarViews = { export const rightSidebarViews = {
pipelines: 'pipelines-list', pipelines: 'pipelines-list',
jobsDetail: 'jobs-detail', jobsDetail: 'jobs-detail',
}; };
export const stageKeys = {
unstaged: 'unstaged',
staged: 'staged',
};
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import * as types from '../mutation_types'; import * as types from '../mutation_types';
import { diffModes } from '../../constants';
export default { export default {
[types.SET_FILE_ACTIVE](state, { path, active }) { [types.SET_FILE_ACTIVE](state, { path, active }) {
...@@ -85,8 +86,19 @@ export default { ...@@ -85,8 +86,19 @@ export default {
}); });
}, },
[types.SET_FILE_MERGE_REQUEST_CHANGE](state, { file, mrChange }) { [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], { Object.assign(state.entries[file.path], {
mrChange, mrChange: {
...mrChange,
diffMode,
},
}); });
}, },
[types.SET_FILE_VIEWMODE](state, { file, viewMode }) { [types.SET_FILE_VIEWMODE](state, { file, viewMode }) {
......
...@@ -39,7 +39,7 @@ export const dataStructure = () => ({ ...@@ -39,7 +39,7 @@ export const dataStructure = () => ({
editorColumn: 1, editorColumn: 1,
fileLanguage: '', fileLanguage: '',
eol: '', eol: '',
viewMode: 'edit', viewMode: 'editor',
previewMode: null, previewMode: null,
size: 0, size: 0,
parentPath: null, parentPath: null,
......
...@@ -56,7 +56,7 @@ export default class MilestoneSelect { ...@@ -56,7 +56,7 @@ export default class MilestoneSelect {
if (issueUpdateURL) { if (issueUpdateURL) {
milestoneLinkTemplate = _.template( 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>'; milestoneLinkNoneTemplate = '<span class="no-value">None</span>';
} }
......
...@@ -62,13 +62,13 @@ export default class UsernameValidator { ...@@ -62,13 +62,13 @@ export default class UsernameValidator {
return this.setPendingState(); return this.setPendingState();
} }
if (!this.state.available) {
return this.setUnavailableState();
}
if (!this.state.valid) { if (!this.state.valid) {
return this.setInvalidState(); return this.setInvalidState();
} }
if (!this.state.available) {
return this.setUnavailableState();
}
} }
interceptInvalid(event) { interceptInvalid(event) {
...@@ -89,7 +89,6 @@ export default class UsernameValidator { ...@@ -89,7 +89,6 @@ export default class UsernameValidator {
setAvailabilityState(usernameTaken) { setAvailabilityState(usernameTaken) {
if (usernameTaken) { if (usernameTaken) {
this.state.valid = false;
this.state.available = false; this.state.available = false;
} else { } else {
this.state.available = true; this.state.available = true;
......
...@@ -32,7 +32,10 @@ export default { ...@@ -32,7 +32,10 @@ export default {
<div class="file-container"> <div class="file-container">
<div class="file-content"> <div class="file-content">
<p class="prepend-top-10 file-info"> <p class="prepend-top-10 file-info">
{{ fileName }} ({{ fileSizeReadable }}) {{ fileName }}
<template v-if="fileSize > 0">
({{ fileSizeReadable }})
</template>
</p> </p>
<a <a
:href="path" :href="path"
......
<script> <script>
import _ from 'underscore';
import { numberToHumanSize } from '../../../../lib/utils/number_utils'; import { numberToHumanSize } from '../../../../lib/utils/number_utils';
export default { export default {
...@@ -12,6 +13,10 @@ export default { ...@@ -12,6 +13,10 @@ export default {
required: false, required: false,
default: 0, default: 0,
}, },
renderInfo: {
type: Boolean,
default: true,
},
}, },
data() { data() {
return { return {
...@@ -26,14 +31,34 @@ export default { ...@@ -26,14 +31,34 @@ export default {
return numberToHumanSize(this.fileSize); 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: { methods: {
onImgLoad() { onImgLoad() {
const contentImg = this.$refs.contentImg; const contentImg = this.$refs.contentImg;
if (contentImg) {
this.isZoomable = this.isZoomable =
contentImg.naturalWidth > contentImg.width || contentImg.naturalHeight > contentImg.height; contentImg.naturalWidth > contentImg.width ||
contentImg.naturalHeight > contentImg.height;
this.width = contentImg.naturalWidth; this.width = contentImg.naturalWidth;
this.height = contentImg.naturalHeight; this.height = contentImg.naturalHeight;
this.$emit('imgLoaded', {
width: this.width,
height: this.height,
renderedWidth: contentImg.clientWidth,
renderedHeight: contentImg.clientHeight,
});
}
}, },
onImgClick() { onImgClick() {
if (this.isZoomable) this.isZoomed = !this.isZoomed; if (this.isZoomable) this.isZoomed = !this.isZoomed;
...@@ -47,20 +72,22 @@ export default { ...@@ -47,20 +72,22 @@ export default {
<div class="file-content image_file"> <div class="file-content image_file">
<img <img
ref="contentImg" ref="contentImg"
:class="{ 'isZoomable': isZoomable, 'isZoomed': isZoomed }" :class="{ 'is-zoomable': isZoomable, 'is-zoomed': isZoomed }"
:src="path" :src="path"
:alt="path" :alt="path"
@load="onImgLoad" @load="onImgLoad"
@click="onImgClick"/> @click="onImgClick"/>
<p class="file-info prepend-top-10"> <p
v-if="renderInfo"
class="file-info prepend-top-10">
<template v-if="fileSize>0"> <template v-if="fileSize>0">
{{ fileSizeReadable }} {{ fileSizeReadable }}
</template> </template>
<template v-if="fileSize>0 && width && height"> <template v-if="fileSize>0 && width && height">
- |
</template> </template>
<template v-if="width && height"> <template v-if="width && height">
{{ width }} x {{ height }} W: {{ width }} | H: {{ height }}
</template> </template>
</p> </p>
</div> </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 { ...@@ -400,3 +400,51 @@ span.idiff {
color: $common-gray-light; color: $common-gray-light;
border: 1px solid $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 @@ ...@@ -189,8 +189,22 @@
img { img {
border: 1px solid $white-light; border: 1px solid $white-light;
background-image: linear-gradient(45deg, $border-color 25%, transparent 25%, transparent 75%, $border-color 75%, $border-color 100%), background-image: linear-gradient(
linear-gradient(45deg, $border-color 25%, transparent 25%, transparent 75%, $border-color 75%, $border-color 100%); 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-size: 10px 10px;
background-position: 0 0, 5px 5px; background-position: 0 0, 5px 5px;
max-width: 100%; max-width: 100%;
...@@ -395,6 +409,69 @@ ...@@ -395,6 +409,69 @@
.line_content { .line_content {
white-space: pre-wrap; 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 { .file-content .diff-file {
...@@ -536,7 +613,7 @@ ...@@ -536,7 +613,7 @@
margin-right: 0; margin-right: 0;
border-color: $white-light; border-color: $white-light;
cursor: pointer; cursor: pointer;
transition: all .1s ease-out; transition: all 0.1s ease-out;
@for $i from 1 through 4 { @for $i from 1 through 4 {
&:nth-child(#{$i}) { &:nth-child(#{$i}) {
...@@ -563,7 +640,7 @@ ...@@ -563,7 +640,7 @@
height: 24px; height: 24px;
border-radius: 50%; border-radius: 50%;
padding: 0; padding: 0;
transition: transform .1s ease-out; transition: transform 0.1s ease-out;
z-index: 100; z-index: 100;
.collapse-icon { .collapse-icon {
...@@ -708,11 +785,35 @@ ...@@ -708,11 +785,35 @@
width: 100%; width: 100%;
height: 10px; height: 10px;
background-color: $white-light; background-color: $white-light;
background-image: linear-gradient(45deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%), background-image: linear-gradient(
linear-gradient(225deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%), 45deg,
linear-gradient(135deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%), transparent,
linear-gradient(-45deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%); transparent 73%,
background-position: 5px 5px,0 5px,0 5px,5px 5px; $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-size: 10px 10px;
background-repeat: repeat; background-repeat: repeat;
} }
...@@ -750,11 +851,16 @@ ...@@ -750,11 +851,16 @@
.frame.click-to-comment { .frame.click-to-comment {
position: relative; position: relative;
cursor: image-url('illustrations/image_comment_light_cursor.svg') 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 // 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) cursor: -webkit-image-set(
$image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto; 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 { .comment-indicator {
position: absolute; position: absolute;
...@@ -840,7 +946,7 @@ ...@@ -840,7 +946,7 @@
.diff-notes-collapse, .diff-notes-collapse,
.note, .note,
.discussion-reply-holder, { .discussion-reply-holder {
display: none; display: none;
} }
......
...@@ -335,7 +335,6 @@ ...@@ -335,7 +335,6 @@
img { img {
max-width: 90%; max-width: 90%;
max-height: 90%;
} }
.isZoomable { .isZoomable {
...@@ -553,6 +552,10 @@ ...@@ -553,6 +552,10 @@
} }
.multi-file-commit-list-item { .multi-file-commit-list-item {
&.is-active {
background-color: $white-normal;
}
.multi-file-discard-btn { .multi-file-discard-btn {
display: none; display: none;
margin-top: -2px; margin-top: -2px;
......
...@@ -19,4 +19,9 @@ class Timelog < ActiveRecord::Base ...@@ -19,4 +19,9 @@ class Timelog < ActiveRecord::Base
errors.add(:base, 'Issue or Merge Request ID is required') errors.add(:base, 'Issue or Merge Request ID is required')
end end
end end
# Rails5 defaults to :touch_later, overwrite for normal touch
def belongs_to_touch_method
:touch
end
end end
...@@ -65,10 +65,10 @@ class FileUploader < GitlabUploader ...@@ -65,10 +65,10 @@ class FileUploader < GitlabUploader
SecureRandom.hex SecureRandom.hex
end end
def upload_paths(filename) def upload_paths(identifier)
[ [
File.join(secret, filename), File.join(secret, identifier),
File.join(base_dir(Store::REMOTE), secret, filename) File.join(base_dir(Store::REMOTE), secret, identifier)
] ]
end end
......
...@@ -10,6 +10,17 @@ module ObjectStorage ...@@ -10,6 +10,17 @@ module ObjectStorage
UnknownStoreError = Class.new(StandardError) UnknownStoreError = Class.new(StandardError)
ObjectStorageUnavailable = 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 TMP_UPLOAD_PATH = 'tmp/uploads'.freeze
module Store module Store
...@@ -29,7 +40,7 @@ module ObjectStorage ...@@ -29,7 +40,7 @@ module ObjectStorage
end end
def retrieve_from_store!(identifier) 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) unless current_upload_satisfies?(paths, model)
# the upload we already have isn't right, find the correct one # the upload we already have isn't right, find the correct one
...@@ -62,6 +73,15 @@ module ObjectStorage ...@@ -62,6 +73,15 @@ module ObjectStorage
upload.id) upload.id)
end 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 private
def current_upload_satisfies?(paths, model) def current_upload_satisfies?(paths, model)
...@@ -261,7 +281,7 @@ module ObjectStorage ...@@ -261,7 +281,7 @@ module ObjectStorage
end end
def delete_migrated_file(migrated_file) def delete_migrated_file(migrated_file)
migrated_file.delete if exists? migrated_file.delete
end end
def exists? def exists?
...@@ -279,6 +299,13 @@ module ObjectStorage ...@@ -279,6 +299,13 @@ module ObjectStorage
} }
end 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) def cache!(new_file = sanitized_file)
# We intercept ::UploadedFile which might be stored on remote storage # We intercept ::UploadedFile which might be stored on remote storage
# We use that for "accelerated" uploads, where we store result on remote storage # We use that for "accelerated" uploads, where we store result on remote storage
...@@ -298,6 +325,10 @@ module ObjectStorage ...@@ -298,6 +325,10 @@ module ObjectStorage
super super
end end
def exclusive_lease_key
"object_storage_migrate:#{model.class}:#{model.id}"
end
private private
def schedule_background_upload? def schedule_background_upload?
...@@ -364,17 +395,14 @@ module ObjectStorage ...@@ -364,17 +395,14 @@ module ObjectStorage
end end
end end
def exclusive_lease_key
"object_storage_migrate:#{model.class}:#{model.id}"
end
def with_exclusive_lease def with_exclusive_lease
uuid = Gitlab::ExclusiveLease.new(exclusive_lease_key, timeout: 1.hour.to_i).try_obtain lease_key = exclusive_lease_key
raise 'exclusive lease already taken' unless uuid uuid = Gitlab::ExclusiveLease.new(lease_key, timeout: 1.hour.to_i).try_obtain
raise ExclusiveLeaseTaken.new(lease_key) unless uuid
yield uuid yield uuid
ensure ensure
Gitlab::ExclusiveLease.cancel(exclusive_lease_key, uuid) Gitlab::ExclusiveLease.cancel(lease_key, uuid)
end end
# #
......
...@@ -22,7 +22,7 @@ module RecordsUploads ...@@ -22,7 +22,7 @@ module RecordsUploads
Upload.transaction do Upload.transaction do
uploads.where(path: upload_path).delete_all uploads.where(path: upload_path).delete_all
upload.destroy! if upload upload.delete if upload
self.upload = build_upload.tap(&:save!) self.upload = build_upload.tap(&:save!)
end end
......
...@@ -169,7 +169,7 @@ ...@@ -169,7 +169,7 @@
.settings-content .settings-content
= render 'logging' = 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 .settings-header
%h4 %h4
= _('Repository storage') = _('Repository storage')
......
- expanded = Rails.env.test? - 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 .settings-header
%h4 %h4
Deploy Keys Deploy Keys
......
...@@ -82,7 +82,7 @@ ...@@ -82,7 +82,7 @@
= render_if_exists 'projects/issues_settings' = 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 .settings-header
%h4 %h4
Merge request Merge request
...@@ -101,7 +101,7 @@ ...@@ -101,7 +101,7 @@
= render 'export', project: @project = 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 .settings-header
%h4 %h4
Advanced Advanced
......
- expanded = Rails.env.test? - 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 .settings-header
%h4 %h4
Protected Branches Protected Branches
......
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
.settings-content .settings-content
= render 'form' = 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 .settings-header
%h4 %h4
= s_('CICD|Auto DevOps') = s_('CICD|Auto DevOps')
...@@ -28,7 +28,7 @@ ...@@ -28,7 +28,7 @@
.settings-content .settings-content
= render 'autodevops_form' = render 'autodevops_form'
%section.settings.no-animate{ class: ('expanded' if expanded) } %section.qa-runners-settings.settings.no-animate{ class: ('expanded' if expanded) }
.settings-header .settings-header
%h4 %h4
Runners Runners
...@@ -39,7 +39,7 @@ ...@@ -39,7 +39,7 @@
.settings-content .settings-content
= render 'projects/runners/index' = 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 .settings-header
%h4 %h4
= _('Variables') = _('Variables')
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
%h4 %h4
= s_('WikiEmpty|The wiki lets you write documentation for your project') = s_('WikiEmpty|The wiki lets you write documentation for your project')
%p.text-left %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 = create_link
- elsif can?(current_user, :read_issue, @project) - 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 ...@@ -12,6 +12,7 @@ module Gitlab
require_dependency Rails.root.join('lib/gitlab/redis/shared_state') 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/request_context')
require_dependency Rails.root.join('lib/gitlab/current_settings') 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. # Settings in config/environments/* take precedence over those specified here.
# Application configuration should go into files in config/initializers # Application configuration should go into files in config/initializers
...@@ -175,7 +176,7 @@ module Gitlab ...@@ -175,7 +176,7 @@ module Gitlab
ENV['GIT_TERMINAL_PROMPT'] = '0' ENV['GIT_TERMINAL_PROMPT'] = '0'
# Gitlab Read-only middleware support # 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| config.generators do |g|
g.factory_bot false g.factory_bot false
......
Rails.application.configure do Rails.application.configure do
# Make sure the middleware is inserted first in middleware chain # 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::RequestBlockerMiddleware)
config.middleware.insert_before('ActionDispatch::Static', 'Gitlab::Testing::RequestInspectorMiddleware') config.middleware.insert_before(ActionDispatch::Static, Gitlab::Testing::RequestInspectorMiddleware)
# Settings specified here will take precedence over those in config/application.rb # Settings specified here will take precedence over those in config/application.rb
......
...@@ -49,6 +49,7 @@ Example response: ...@@ -49,6 +49,7 @@ Example response:
"title": "test", "title": "test",
"file_name": "add.rb", "file_name": "add.rb",
"description": "Ruby test snippet", "description": "Ruby test snippet",
"visibility": "private",
"author": { "author": {
"id": 1, "id": 1,
"username": "john_smith", "username": "john_smith",
...@@ -99,6 +100,7 @@ Example response: ...@@ -99,6 +100,7 @@ Example response:
"title": "This is a snippet", "title": "This is a snippet",
"file_name": "test.txt", "file_name": "test.txt",
"description": "Hello World snippet", "description": "Hello World snippet",
"visibility": "internal",
"author": { "author": {
"id": 1, "id": 1,
"username": "john_smith", "username": "john_smith",
...@@ -150,6 +152,7 @@ Example response: ...@@ -150,6 +152,7 @@ Example response:
"title": "test", "title": "test",
"file_name": "add.rb", "file_name": "add.rb",
"description": "description of snippet", "description": "description of snippet",
"visibility": "internal",
"author": { "author": {
"id": 1, "id": 1,
"username": "john_smith", "username": "john_smith",
...@@ -238,7 +241,8 @@ Example response: ...@@ -238,7 +241,8 @@ Example response:
"raw_url": "http://localhost:3000/snippets/48/raw", "raw_url": "http://localhost:3000/snippets/48/raw",
"title": "Minus similique nesciunt vel fugiat qui ullam sunt.", "title": "Minus similique nesciunt vel fugiat qui ullam sunt.",
"updated_at": "2016-11-25T16:53:34.479Z", "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. ...@@ -237,13 +237,15 @@ Issue Board, that is create/delete lists and drag issues around.
## Group Issue Board ## 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 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 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 boards. When updating milestones and labels for an issue through the sidebar update mechanism, again only
group-level objects are available. 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 ## Features per tier
Different issue board features are available in different [GitLab tiers](https://about.gitlab.com/pricing/), as shown in the following table: 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 ...@@ -362,7 +362,7 @@ module API
end end
class Snippet < Grape::Entity class Snippet < Grape::Entity
expose :id, :title, :file_name, :description expose :id, :title, :file_name, :description, :visibility
expose :author, using: Entities::UserBasic expose :author, using: Entities::UserBasic
expose :updated_at, :created_at expose :updated_at, :created_at
expose :project_id expose :project_id
...@@ -416,6 +416,10 @@ module API ...@@ -416,6 +416,10 @@ module API
expose :state, :created_at, :updated_at expose :state, :created_at, :updated_at
expose :due_date expose :due_date
expose :start_date expose :start_date
expose :web_url do |milestone, _options|
Gitlab::UrlBuilder.build(milestone)
end
end end
class IssueBasic < ProjectEntity class IssueBasic < ProjectEntity
......
...@@ -65,7 +65,7 @@ module Banzai ...@@ -65,7 +65,7 @@ module Banzai
# We don't support IID lookups for group milestones, because IIDs can # We don't support IID lookups for group milestones, because IIDs can
# clash between group and project milestones. # clash between group and project milestones.
if project.group && !params[:iid] if project.group && !params[:iid]
finder_params[:group_ids] = [project.group.id] finder_params[:group_ids] = project.group.self_and_ancestors.select(:id)
end end
MilestonesFinder.new(finder_params).find_by(params) MilestonesFinder.new(finder_params).find_by(params)
......
...@@ -39,7 +39,7 @@ module Gitlab ...@@ -39,7 +39,7 @@ module Gitlab
content.delete!("\r") content.delete!("\r")
content.gsub!(commands_regex) do content.gsub!(commands_regex) do
if $~[:cmd] if $~[:cmd]
commands << [$~[:cmd], $~[:arg]].reject(&:blank?) commands << [$~[:cmd].downcase, $~[:arg]].reject(&:blank?)
'' ''
else else
$~[0] $~[0]
...@@ -102,14 +102,14 @@ module Gitlab ...@@ -102,14 +102,14 @@ module Gitlab
# /close # /close
^\/ ^\/
(?<cmd>#{Regexp.union(names)}) (?<cmd>#{Regexp.new(Regexp.union(names).source, Regexp::IGNORECASE)})
(?: (?:
[ ] [ ]
(?<arg>[^\n]*) (?<arg>[^\n]*)
)? )?
(?:\n|$) (?:\n|$)
) )
}mx }mix
end end
def perform_substitutions(content, commands) def perform_substitutions(content, commands)
...@@ -120,7 +120,7 @@ module Gitlab ...@@ -120,7 +120,7 @@ module Gitlab
end end
substitution_definitions.each do |substitution| substitution_definitions.each do |substitution|
match_data = substitution.match(content) match_data = substitution.match(content.downcase)
if match_data if match_data
command = [substitution.name.to_s] command = [substitution.name.to_s]
command << match_data[1] unless match_data[1].empty? command << match_data[1] unless match_data[1].empty?
......
...@@ -15,7 +15,7 @@ module Gitlab ...@@ -15,7 +15,7 @@ module Gitlab
return unless content return unless content
all_names.each do |a_name| 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 end
content content
end end
......
...@@ -26,6 +26,8 @@ module Gitlab ...@@ -26,6 +26,8 @@ module Gitlab
project_snippet_url(object.project, object) project_snippet_url(object.project, object)
when Snippet when Snippet
snippet_url(object) snippet_url(object)
when Milestone
milestone_url(object)
else else
raise NotImplementedError.new("No URL builder defined for #{object.class}") raise NotImplementedError.new("No URL builder defined for #{object.class}")
end end
......
...@@ -4734,7 +4734,7 @@ msgstr "" ...@@ -4734,7 +4734,7 @@ msgstr ""
msgid "WikiEmptyIssueMessage|issue tracker" msgid "WikiEmptyIssueMessage|issue tracker"
msgstr "" 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 "" msgstr ""
msgid "WikiEmpty|Create your first page" msgid "WikiEmpty|Create your first page"
......
...@@ -6,11 +6,11 @@ module QA ...@@ -6,11 +6,11 @@ module QA
include QA::Page::Settings::Common include QA::Page::Settings::Common
view 'app/views/admin/application_settings/show.html.haml' do view 'app/views/admin/application_settings/show.html.haml' do
element :advanced_settings_section, 'Repository storage' element :repository_storage_settings
end end
def expand_repository_storage(&block) def expand_repository_storage(&block)
expand_section('Repository storage') do expand_section(:repository_storage_settings) do
RepositoryStorage.perform(&block) RepositoryStorage.perform(&block)
end end
end end
......
...@@ -4,9 +4,9 @@ module QA ...@@ -4,9 +4,9 @@ module QA
module Settings module Settings
class Advanced < Page::Base class Advanced < Page::Base
view 'app/views/projects/edit.html.haml' do view 'app/views/projects/edit.html.haml' do
element :project_path_field, 'f.text_field :path' element :project_path_field, 'text_field :path'
element :project_name_field, 'f.text_field :name' element :project_name_field, 'text_field :name'
element :rename_project_button, "f.submit 'Rename project'" element :rename_project_button, "submit 'Rename project'"
end end
def rename_to(path) def rename_to(path)
......
...@@ -6,31 +6,33 @@ module QA # rubocop:disable Naming/FileName ...@@ -6,31 +6,33 @@ module QA # rubocop:disable Naming/FileName
include Common include Common
view 'app/views/projects/settings/ci_cd/show.html.haml' do view 'app/views/projects/settings/ci_cd/show.html.haml' do
element :runners_settings, 'Runners' element :autodevops_settings
element :secret_variables, 'Variables' element :runners_settings
element :auto_devops_section, 'Auto DevOps' element :variables_settings
end end
view 'app/views/projects/settings/ci_cd/_autodevops_form.html.haml' do view 'app/views/projects/settings/ci_cd/_autodevops_form.html.haml' do
element :enable_auto_devops_button, 'Enable Auto DevOps' element :enable_auto_devops_field, 'radio_button :enabled'
element :domain_input, 'Domain' 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'" element :save_changes_button, "submit 'Save changes'"
end end
def expand_runners_settings(&block) def expand_runners_settings(&block)
expand_section('Runners') do expand_section(:runners_settings) do
Settings::Runners.perform(&block) Settings::Runners.perform(&block)
end end
end end
def expand_secret_variables(&block) def expand_secret_variables(&block)
expand_section('Variables') do expand_section(:variables_settings) do
Settings::SecretVariables.perform(&block) Settings::SecretVariables.perform(&block)
end end
end end
def enable_auto_devops_with_domain(domain) def enable_auto_devops_with_domain(domain)
expand_section('Auto DevOps') do expand_section(:autodevops_settings) do
choose 'Enable Auto DevOps' choose 'Enable Auto DevOps'
fill_in 'Domain', with: domain fill_in 'Domain', with: domain
click_on 'Save changes' click_on 'Save changes'
......
...@@ -6,11 +6,11 @@ module QA ...@@ -6,11 +6,11 @@ module QA
include Common include Common
view 'app/views/projects/edit.html.haml' do view 'app/views/projects/edit.html.haml' do
element :advanced_settings_section, 'Advanced' element :advanced_settings
end end
def expand_advanced_settings(&block) def expand_advanced_settings(&block)
expand_section('Advanced settings') do expand_section(:advanced_settings) do
Advanced.perform(&block) Advanced.perform(&block)
end end
end end
......
...@@ -5,17 +5,17 @@ module QA ...@@ -5,17 +5,17 @@ module QA
class MergeRequest < QA::Page::Base class MergeRequest < QA::Page::Base
include Common 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 view 'app/views/projects/edit.html.haml' do
element :merge_request_settings, 'Merge request' element :merge_request_settings
element :save_merge_request_changes element :save_merge_request_changes
end end
view 'app/views/projects/_merge_request_merge_method_settings.html.haml' do
element :radio_button_merge_ff
end
def enable_ff_only def enable_ff_only
expand_section('Merge request') do expand_section(:merge_request_settings) do
click_element :radio_button_merge_ff click_element :radio_button_merge_ff
click_element :save_merge_request_changes click_element :save_merge_request_changes
end end
......
...@@ -6,17 +6,21 @@ module QA ...@@ -6,17 +6,21 @@ module QA
include Common include Common
view 'app/views/projects/deploy_keys/_index.html.haml' do 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 end
def expand_deploy_keys(&block) def expand_deploy_keys(&block)
expand_section('Deploy Keys') do expand_section(:deploy_keys_settings) do
DeployKeys.perform(&block) DeployKeys.perform(&block)
end end
end end
def expand_protected_branches(&block) def expand_protected_branches(&block)
expand_section('Protected Branches') do expand_section(:protected_branches_settings) do
ProtectedBranches.perform(&block) ProtectedBranches.perform(&block)
end end
end end
......
...@@ -4,10 +4,9 @@ module QA ...@@ -4,10 +4,9 @@ module QA
module Common module Common
# Click the Expand button present in the specified section # Click the Expand button present in the specified section
# #
# @param [String] name present in the container in the DOM # @param [Symbol] and `element` name defined in a `view` block
def expand_section(name) def expand_section(element_name)
page.within('#content-body') do within_element(element_name) do
page.within('section', text: name) do
# Because it is possible to click the button before the JS toggle code is bound # Because it is possible to click the button before the JS toggle code is bound
wait(reload: false) do wait(reload: false) do
click_button 'Expand' unless first('button', text: 'Collapse') click_button 'Expand' unless first('button', text: 'Collapse')
...@@ -21,5 +20,4 @@ module QA ...@@ -21,5 +20,4 @@ module QA
end end
end end
end end
end
end end
...@@ -11,7 +11,7 @@ module QA ...@@ -11,7 +11,7 @@ module QA
expect(page).to have_content('This is a merge request') expect(page).to have_content('This is a merge request')
expect(page).to have_content('Great feature') 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 end
end end
...@@ -13,7 +13,8 @@ ...@@ -13,7 +13,8 @@
"created_at": { "type": "date" }, "created_at": { "type": "date" },
"updated_at": { "type": "date" }, "updated_at": { "type": "date" },
"start_date": { "type": "date" }, "start_date": { "type": "date" },
"due_date": { "type": "date" } "due_date": { "type": "date" },
"web_url": { "type": "string" }
}, },
"required": [ "required": [
"id", "iid", "title", "description", "state", "id", "iid", "title", "description", "state",
......
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
"title": { "type": "string" }, "title": { "type": "string" },
"file_name": { "type": ["string", "null"] }, "file_name": { "type": ["string", "null"] },
"description": { "type": ["string", "null"] }, "description": { "type": ["string", "null"] },
"visibility": { "type": "string" },
"web_url": { "type": "string" }, "web_url": { "type": "string" },
"created_at": { "type": "date" }, "created_at": { "type": "date" },
"updated_at": { "type": "date" }, "updated_at": { "type": "date" },
......
...@@ -19,6 +19,7 @@ describe('Multi-file editor commit sidebar list item', () => { ...@@ -19,6 +19,7 @@ describe('Multi-file editor commit sidebar list item', () => {
vm = createComponentWithStore(Component, store, { vm = createComponentWithStore(Component, store, {
file: f, file: f,
actionComponent: 'stage-button', actionComponent: 'stage-button',
activeFileKey: `staged-${f.key}`,
}).$mount(); }).$mount();
}); });
...@@ -89,4 +90,20 @@ describe('Multi-file editor commit sidebar list item', () => { ...@@ -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', () => { ...@@ -17,6 +17,8 @@ describe('Multi-file editor commit sidebar list', () => {
action: 'stageAllChanges', action: 'stageAllChanges',
actionBtnText: 'stage all', actionBtnText: 'stage all',
itemActionComponent: 'stage-button', itemActionComponent: 'stage-button',
activeFileKey: 'staged-testing',
keyPrefix: 'staged',
}); });
vm.$store.state.rightPanelCollapsed = false; vm.$store.state.rightPanelCollapsed = false;
......
...@@ -56,7 +56,7 @@ describe('RepoCommitSection', () => { ...@@ -56,7 +56,7 @@ describe('RepoCommitSection', () => {
vm.$store.state.entries[f.path] = f; vm.$store.state.entries[f.path] = f;
}); });
return vm.$mount(); return vm;
} }
beforeEach(done => { beforeEach(done => {
...@@ -64,6 +64,10 @@ describe('RepoCommitSection', () => { ...@@ -64,6 +64,10 @@ describe('RepoCommitSection', () => {
vm = createComponent(); vm = createComponent();
spyOn(vm, 'openPendingTab').and.callThrough();
vm.$mount();
spyOn(service, 'getTreeData').and.returnValue( spyOn(service, 'getTreeData').and.returnValue(
Promise.resolve({ Promise.resolve({
headers: { headers: {
...@@ -98,6 +102,7 @@ describe('RepoCommitSection', () => { ...@@ -98,6 +102,7 @@ describe('RepoCommitSection', () => {
store.state.noChangesStateSvgPath = 'nochangessvg'; store.state.noChangesStateSvgPath = 'nochangessvg';
store.state.committedStateSvgPath = 'svg'; store.state.committedStateSvgPath = 'svg';
vm.$destroy();
vm = createComponentWithStore(Component, store).$mount(); vm = createComponentWithStore(Component, store).$mount();
expect(vm.$el.querySelector('.js-empty-state').textContent.trim()).toContain('No changes'); expect(vm.$el.querySelector('.js-empty-state').textContent.trim()).toContain('No changes');
...@@ -176,5 +181,12 @@ describe('RepoCommitSection', () => { ...@@ -176,5 +181,12 @@ describe('RepoCommitSection', () => {
expect(store.state.openFiles.length).toBe(1); expect(store.state.openFiles.length).toBe(1);
expect(store.state.openFiles[0].pending).toBe(true); 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', () => { ...@@ -152,6 +152,53 @@ describe('IDE store file mutations', () => {
expect(localFile.mrChange.diff).toBe('ABC'); 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', () => { describe('DISCARD_FILE_CHANGES', () => {
......
...@@ -2,3 +2,6 @@ export const FIXTURES_PATH = '/base/spec/javascripts/fixtures'; ...@@ -2,3 +2,6 @@ export const FIXTURES_PATH = '/base/spec/javascripts/fixtures';
export const TEST_HOST = 'http://test.host'; export const TEST_HOST = 'http://test.host';
export const DUMMY_IMAGE_URL = `${FIXTURES_PATH}/one_white_pixel.png`; 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'; ...@@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import contentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import contentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { GREEN_BOX_IMAGE_URL } from 'spec/test_constants';
describe('ContentViewer', () => { describe('ContentViewer', () => {
let vm; let vm;
...@@ -41,12 +42,12 @@ describe('ContentViewer', () => { ...@@ -41,12 +42,12 @@ describe('ContentViewer', () => {
it('renders image preview', done => { it('renders image preview', done => {
createComponent({ createComponent({
path: 'test.jpg', path: GREEN_BOX_IMAGE_URL,
fileSize: 1024, fileSize: 1024,
}); });
setTimeout(() => { 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(); done();
}); });
...@@ -59,9 +60,8 @@ describe('ContentViewer', () => { ...@@ -59,9 +60,8 @@ describe('ContentViewer', () => {
}); });
setTimeout(() => { setTimeout(() => {
expect(vm.$el.querySelector('.file-info').textContent.trim()).toContain( expect(vm.$el.querySelector('.file-info').textContent.trim()).toContain('test.abc');
'test.abc (1.00 KiB)', expect(vm.$el.querySelector('.file-info').textContent.trim()).toContain('(1.00 KiB)');
);
expect(vm.$el.querySelector('.btn.btn-default').textContent.trim()).toContain('Download'); expect(vm.$el.querySelector('.btn.btn-default').textContent.trim()).toContain('Download');
done(); 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' ...@@ -3,7 +3,8 @@ require 'spec_helper'
describe Banzai::Filter::MilestoneReferenceFilter do describe Banzai::Filter::MilestoneReferenceFilter do
include FilterSpecHelper 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) } let(:project) { create(:project, :public, group: group) }
it 'requires project context' do it 'requires project context' do
...@@ -340,6 +341,13 @@ describe Banzai::Filter::MilestoneReferenceFilter do ...@@ -340,6 +341,13 @@ describe Banzai::Filter::MilestoneReferenceFilter do
expect(doc.css('a')).to be_empty expect(doc.css('a')).to be_empty
end 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 end
context 'group context' do context 'group context' do
......
...@@ -182,6 +182,14 @@ describe Gitlab::QuickActions::Extractor do ...@@ -182,6 +182,14 @@ describe Gitlab::QuickActions::Extractor do
expect(msg).to eq "hello\nworld" expect(msg).to eq "hello\nworld"
end 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 it 'does not extract noop commands' do
msg = %(hello\nworld\n/reopen\n/noop_command) msg = %(hello\nworld\n/reopen\n/noop_command)
msg, commands = extractor.extract_commands(msg) msg, commands = extractor.extract_commands(msg)
...@@ -206,6 +214,14 @@ describe Gitlab::QuickActions::Extractor do ...@@ -206,6 +214,14 @@ describe Gitlab::QuickActions::Extractor do
expect(msg).to eq "hello\nworld\nthis is great? SHRUG" expect(msg).to eq "hello\nworld\nthis is great? SHRUG"
end 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 it 'extracts and performs substitution commands with comments' do
msg = %(hello\nworld\n/reopen\n/substitution wow this is a thing.) msg = %(hello\nworld\n/reopen\n/substitution wow this is a thing.)
msg, commands = extractor.extract_commands(msg) msg, commands = extractor.extract_commands(msg)
......
...@@ -22,6 +22,31 @@ describe Gitlab::UrlBuilder do ...@@ -22,6 +22,31 @@ describe Gitlab::UrlBuilder do
end end
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 context 'when passing a MergeRequest' do
it 'returns a proper URL' do it 'returns a proper URL' do
merge_request = build_stubbed(:merge_request, iid: 42) merge_request = build_stubbed(:merge_request, iid: 42)
......
...@@ -20,6 +20,7 @@ describe API::Snippets do ...@@ -20,6 +20,7 @@ describe API::Snippets do
private_snippet.id) private_snippet.id)
expect(json_response.last).to have_key('web_url') 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('raw_url')
expect(json_response.last).to have_key('visibility')
end end
it 'hides private snippets from regular user' do it 'hides private snippets from regular user' do
...@@ -112,6 +113,7 @@ describe API::Snippets do ...@@ -112,6 +113,7 @@ describe API::Snippets do
expect(json_response['title']).to eq(snippet.title) expect(json_response['title']).to eq(snippet.title)
expect(json_response['description']).to eq(snippet.description) expect(json_response['description']).to eq(snippet.description)
expect(json_response['file_name']).to eq(snippet.file_name) expect(json_response['file_name']).to eq(snippet.file_name)
expect(json_response['visibility']).to eq(snippet.visibility)
end end
it 'returns 404 for invalid snippet id' do it 'returns 404 for invalid snippet id' do
...@@ -142,6 +144,7 @@ describe API::Snippets do ...@@ -142,6 +144,7 @@ describe API::Snippets do
expect(json_response['title']).to eq(params[:title]) expect(json_response['title']).to eq(params[:title])
expect(json_response['description']).to eq(params[:description]) expect(json_response['description']).to eq(params[:description])
expect(json_response['file_name']).to eq(params[:file_name]) expect(json_response['file_name']).to eq(params[:file_name])
expect(json_response['visibility']).to eq(params[:visibility])
end end
it 'returns 400 for missing parameters' do it 'returns 400 for missing parameters' do
......
...@@ -76,26 +76,24 @@ shared_examples "migrates" do |to_store:, from_store: nil| ...@@ -76,26 +76,24 @@ shared_examples "migrates" do |to_store:, from_store: nil|
end end
context 'when migrate! is occupied by another process' do context 'when migrate! is occupied by another process' do
let(:exclusive_lease_key) { "object_storage_migrate:#{subject.model.class}:#{subject.model.id}" }
before do 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 end
it 'does not execute migrate!' do it 'does not execute migrate!' do
expect(subject).not_to receive(:unsafe_migrate!) 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 end
it 'does not execute use_file' do it 'does not execute use_file' do
expect(subject).not_to receive(:unsafe_use_file) 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 end
after do after do
Gitlab::ExclusiveLease.cancel(exclusive_lease_key, @uuid) Gitlab::ExclusiveLease.cancel(subject.exclusive_lease_key, @uuid)
end end
end end
......
...@@ -321,7 +321,7 @@ describe ObjectStorage do ...@@ -321,7 +321,7 @@ describe ObjectStorage do
when_file_is_in_use do when_file_is_in_use do
expect(uploader).not_to receive(:unsafe_migrate!) 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
end end
...@@ -329,7 +329,19 @@ describe ObjectStorage do ...@@ -329,7 +329,19 @@ describe ObjectStorage do
when_file_is_in_use do when_file_is_in_use do
expect(uploader).not_to receive(:unsafe_use_file) 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 end
end end
......
...@@ -11,6 +11,12 @@ describe ObjectStorage::MigrateUploadsWorker, :sidekiq do ...@@ -11,6 +11,12 @@ describe ObjectStorage::MigrateUploadsWorker, :sidekiq do
let(:uploads) { Upload.all } let(:uploads) { Upload.all }
let(:to_store) { ObjectStorage::Store::REMOTE } 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 shared_examples "uploads migration worker" do
describe '.enqueue!' do describe '.enqueue!' do
def enqueue! def enqueue!
...@@ -69,12 +75,6 @@ describe ObjectStorage::MigrateUploadsWorker, :sidekiq do ...@@ -69,12 +75,6 @@ describe ObjectStorage::MigrateUploadsWorker, :sidekiq do
end end
describe '#perform' do 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| shared_examples 'outputs correctly' do |success: 0, failures: 0|
total = success + failures total = success + failures
...@@ -82,7 +82,7 @@ describe ObjectStorage::MigrateUploadsWorker, :sidekiq do ...@@ -82,7 +82,7 @@ describe ObjectStorage::MigrateUploadsWorker, :sidekiq do
it 'outputs the reports' do it 'outputs the reports' do
expect(Rails.logger).to receive(:info).with(%r{Migrated #{success}/#{total} files}) expect(Rails.logger).to receive(:info).with(%r{Migrated #{success}/#{total} files})
perform perform(uploads)
end end
end end
...@@ -90,7 +90,7 @@ describe ObjectStorage::MigrateUploadsWorker, :sidekiq do ...@@ -90,7 +90,7 @@ describe ObjectStorage::MigrateUploadsWorker, :sidekiq do
it 'outputs upload failures' do it 'outputs upload failures' do
expect(Rails.logger).to receive(:warn).with(/Error .* I am a teapot/) expect(Rails.logger).to receive(:warn).with(/Error .* I am a teapot/)
perform perform(uploads)
end end
end end
end end
...@@ -98,7 +98,7 @@ describe ObjectStorage::MigrateUploadsWorker, :sidekiq do ...@@ -98,7 +98,7 @@ describe ObjectStorage::MigrateUploadsWorker, :sidekiq do
it_behaves_like 'outputs correctly', success: 10 it_behaves_like 'outputs correctly', success: 10
it 'migrates files' do it 'migrates files' do
perform perform(uploads)
expect(Upload.where(store: ObjectStorage::Store::LOCAL).count).to eq(0) expect(Upload.where(store: ObjectStorage::Store::LOCAL).count).to eq(0)
end end
...@@ -123,6 +123,17 @@ describe ObjectStorage::MigrateUploadsWorker, :sidekiq do ...@@ -123,6 +123,17 @@ describe ObjectStorage::MigrateUploadsWorker, :sidekiq do
end end
it_behaves_like "uploads migration worker" 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 end
context "for FileUploader" do context "for FileUploader" do
...@@ -130,15 +141,29 @@ describe ObjectStorage::MigrateUploadsWorker, :sidekiq do ...@@ -130,15 +141,29 @@ describe ObjectStorage::MigrateUploadsWorker, :sidekiq do
let(:secret) { SecureRandom.hex } let(:secret) { SecureRandom.hex }
let(:mounted_as) { nil } let(:mounted_as) { nil }
before do def upload_file(project)
stub_uploads_object_storage(FileUploader)
projects.map do |project|
uploader = FileUploader.new(project) uploader = FileUploader.new(project)
uploader.store!(fixture_file_upload('spec/fixtures/doc_sample.txt')) uploader.store!(fixture_file_upload('spec/fixtures/doc_sample.txt'))
end end
before do
stub_uploads_object_storage(FileUploader)
projects.map(&method(:upload_file))
end end
it_behaves_like "uploads migration worker" 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
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