Commit 03416324 authored by Stan Hu's avatar Stan Hu

Merge branch 'ce-to-ee-2018-09-07' into 'master'

CE upstream - 2018-09-07 16:58 UTC

Closes #6948, gitlab-ce#51225, gitlab-ce#40529, gitaly#954, and #7482

See merge request gitlab-org/gitlab-ee!7285
parents bef4c774 a3e92224
<script>
import $ from 'jquery';
import { mapActions } from 'vuex';
import { __ } from '~/locale';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import ChangedFileIcon from '../changed_file_icon.vue';
export default {
components: {
FileIcon,
ChangedFileIcon,
},
props: {
activeFile: {
type: Object,
required: true,
},
},
computed: {
activeButtonText() {
return this.activeFile.staged ? __('Unstage') : __('Stage');
},
isStaged() {
return !this.activeFile.changed && this.activeFile.staged;
},
},
methods: {
...mapActions(['stageChange', 'unstageChange']),
actionButtonClicked() {
if (this.activeFile.staged) {
this.unstageChange(this.activeFile.path);
} else {
this.stageChange(this.activeFile.path);
}
},
showDiscardModal() {
$(document.getElementById(`discard-file-${this.activeFile.path}`)).modal('show');
},
},
};
</script>
<template>
<div class="d-flex ide-commit-editor-header align-items-center">
<file-icon
:file-name="activeFile.name"
:size="16"
class="mr-2"
/>
<strong class="mr-2">
{{ activeFile.path }}
</strong>
<changed-file-icon
:file="activeFile"
/>
<div class="ml-auto">
<button
v-if="!isStaged"
type="button"
class="btn btn-remove btn-inverted append-right-8"
@click="showDiscardModal"
>
{{ __('Discard') }}
</button>
<button
:class="{
'btn-success': !isStaged,
'btn-warning': isStaged
}"
type="button"
class="btn btn-inverted"
@click="actionButtonClicked"
>
{{ activeButtonText }}
</button>
</div>
</div>
</template>
<script>
import $ from 'jquery';
import { mapActions } from 'vuex';
import { __, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import GlModal from '~/vue_shared/components/gl_modal.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import ListItem from './list_item.vue';
......@@ -9,6 +11,7 @@ export default {
components: {
Icon,
ListItem,
GlModal,
},
directives: {
tooltip,
......@@ -56,6 +59,11 @@ export default {
type: String,
required: true,
},
emptyStateText: {
type: String,
required: false,
default: __('No changes'),
},
},
computed: {
titleText() {
......@@ -68,11 +76,19 @@ export default {
},
},
methods: {
...mapActions(['stageAllChanges', 'unstageAllChanges']),
...mapActions(['stageAllChanges', 'unstageAllChanges', 'discardAllChanges']),
actionBtnClicked() {
this[this.action]();
$(this.$refs.actionBtn).tooltip('hide');
},
openDiscardModal() {
$('#discard-all-changes').modal('show');
},
},
discardModalText: __(
"You will loose all the unstaged changes you've made in this project. This action cannot be undone.",
),
};
</script>
......@@ -81,27 +97,32 @@ export default {
class="ide-commit-list-container"
>
<header
class="multi-file-commit-panel-header"
class="multi-file-commit-panel-header d-flex mb-0"
>
<div
class="multi-file-commit-panel-header-title"
class="d-flex align-items-center flex-fill"
>
<icon
v-once
:name="iconName"
:size="18"
class="append-right-8"
/>
<strong>
{{ titleText }}
</strong>
<div class="d-flex ml-auto">
<button
v-tooltip
v-show="filesLength"
ref="actionBtn"
:title="actionBtnText"
:aria-label="actionBtnText"
:disabled="!filesLength"
:class="{
'd-flex': filesLength
'disabled-content': !filesLength
}"
:title="actionBtnText"
type="button"
class="btn btn-default ide-staged-action-btn p-0 order-1 align-items-center"
class="d-flex ide-staged-action-btn p-0 border-0 align-items-center"
data-placement="bottom"
data-container="body"
data-boundary="viewport"
......@@ -109,18 +130,32 @@ export default {
>
<icon
:name="actionBtnIcon"
:size="12"
:size="16"
class="ml-auto mr-auto"
/>
</button>
<span
<button
v-tooltip
v-if="!stagedList"
:title="__('Discard all changes')"
:aria-label="__('Discard all changes')"
:disabled="!filesLength"
:class="{
'rounded-right': !filesLength
'disabled-content': !filesLength
}"
class="ide-commit-file-count order-0 rounded-left text-center"
type="button"
class="d-flex ide-staged-action-btn p-0 border-0 align-items-center"
data-placement="bottom"
data-container="body"
data-boundary="viewport"
@click="openDiscardModal"
>
{{ filesLength }}
</span>
<icon
:size="16"
name="remove-all"
class="ml-auto mr-auto"
/>
</button>
</div>
</div>
</header>
......@@ -143,9 +178,19 @@ export default {
</ul>
<p
v-else
class="multi-file-commit-list form-text text-muted"
class="multi-file-commit-list form-text text-muted text-center"
>
{{ __('No changes') }}
{{ emptyStateText }}
</p>
<gl-modal
v-if="!stagedList"
id="discard-all-changes"
:footer-primary-button-text="__('Discard all changes')"
:header-title-text="__('Discard all unstaged changes?')"
footer-primary-button-variant="danger"
@submit="discardAllChanges"
>
{{ $options.discardModalText }}
</gl-modal>
</div>
</template>
......@@ -2,6 +2,7 @@
import { mapActions } from 'vuex';
import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import StageButton from './stage_button.vue';
import UnstageButton from './unstage_button.vue';
import { viewerTypes } from '../../constants';
......@@ -12,6 +13,7 @@ export default {
Icon,
StageButton,
UnstageButton,
FileIcon,
},
directives: {
tooltip,
......@@ -48,7 +50,7 @@ export default {
return `${getCommitIconMap(this.file).icon}${suffix}`;
},
iconClass() {
return `${getCommitIconMap(this.file).class} append-right-8`;
return `${getCommitIconMap(this.file).class} ml-auto mr-auto`;
},
fullKey() {
return `${this.keyPrefix}-${this.file.key}`;
......@@ -105,17 +107,24 @@ export default {
@click="openFileInEditor"
>
<span class="multi-file-commit-list-file-path d-flex align-items-center">
<file-icon
:file-name="file.name"
class="append-right-8"
/>{{ file.name }}
</span>
<div class="ml-auto d-flex align-items-center">
<div class="d-flex align-items-center ide-commit-list-changed-icon">
<icon
:name="iconName"
:size="16"
:css-classes="iconClass"
/>{{ file.name }}
</span>
/>
</div>
<component
:is="actionComponent"
:path="file.path"
class="d-flex position-absolute"
/>
</div>
</div>
</div>
</template>
<script>
import $ from 'jquery';
import { mapActions } from 'vuex';
import { sprintf, __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import GlModal from '~/vue_shared/components/gl_modal.vue';
export default {
components: {
Icon,
GlModal,
},
directives: {
tooltip,
......@@ -16,8 +20,22 @@ export default {
required: true,
},
},
computed: {
modalId() {
return `discard-file-${this.path}`;
},
modalTitle() {
return sprintf(
__('Discard changes to %{path}?'),
{ path: this.path },
);
},
},
methods: {
...mapActions(['stageChange', 'discardFileChanges']),
showDiscardModal() {
$(document.getElementById(this.modalId)).modal('show');
},
},
};
</script>
......@@ -25,51 +43,50 @@ export default {
<template>
<div
v-once
class="multi-file-discard-btn dropdown"
class="multi-file-discard-btn d-flex"
>
<button
v-tooltip
:aria-label="__('Stage changes')"
:title="__('Stage changes')"
type="button"
class="btn btn-blank append-right-5 d-flex align-items-center"
class="btn btn-blank align-items-center"
data-container="body"
data-boundary="viewport"
data-placement="bottom"
@click.stop="stageChange(path)"
@click.stop.prevent="stageChange(path)"
>
<icon
:size="12"
:size="16"
name="mobile-issue-close"
class="ml-auto mr-auto"
/>
</button>
<button
v-tooltip
:title="__('More actions')"
:aria-label="__('Discard changes')"
:title="__('Discard changes')"
type="button"
class="btn btn-blank d-flex align-items-center"
class="btn btn-blank align-items-center"
data-container="body"
data-boundary="viewport"
data-placement="bottom"
data-toggle="dropdown"
data-display="static"
@click.stop.prevent="showDiscardModal"
>
<icon
:size="12"
name="ellipsis_h"
:size="16"
name="remove"
class="ml-auto mr-auto"
/>
</button>
<div class="dropdown-menu dropdown-menu-right">
<ul>
<li>
<button
type="button"
@click.stop="discardFileChanges(path)"
<gl-modal
:id="modalId"
:header-title-text="modalTitle"
:footer-primary-button-text="__('Discard changes')"
footer-primary-button-variant="danger"
@submit="discardFileChanges(path)"
>
{{ __('Discard changes') }}
</button>
</li>
</ul>
</div>
{{ __("You will loose all changes you've made to this file. This action cannot be undone.") }}
</gl-modal>
</div>
</template>
......@@ -25,22 +25,23 @@ export default {
<template>
<div
v-once
class="multi-file-discard-btn"
class="multi-file-discard-btn d-flex"
>
<button
v-tooltip
:aria-label="__('Unstage changes')"
:title="__('Unstage changes')"
type="button"
class="btn btn-blank d-flex align-items-center"
class="btn btn-blank align-items-center"
data-container="body"
data-boundary="viewport"
data-placement="bottom"
@click="unstageChange(path)"
@click.stop.prevent="unstageChange(path)"
>
<icon
:size="12"
name="history"
:size="16"
name="redo"
class="ml-auto mr-auto"
/>
</button>
</div>
......
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import Dropdown from './dropdown.vue';
export default {
components: {
Dropdown,
},
computed: {
...mapGetters(['activeFile']),
...mapGetters('fileTemplates', ['templateTypes']),
...mapState('fileTemplates', ['selectedTemplateType', 'updateSuccess']),
showTemplatesDropdown() {
return Object.keys(this.selectedTemplateType).length > 0;
},
},
watch: {
activeFile: 'setInitialType',
},
mounted() {
this.setInitialType();
},
methods: {
...mapActions('fileTemplates', [
'setSelectedTemplateType',
'fetchTemplate',
'undoFileTemplate',
]),
setInitialType() {
const initialTemplateType = this.templateTypes.find(t => t.name === this.activeFile.name);
if (initialTemplateType) {
this.setSelectedTemplateType(initialTemplateType);
}
},
selectTemplateType(templateType) {
this.setSelectedTemplateType(templateType);
},
selectTemplate(template) {
this.fetchTemplate(template);
},
undo() {
this.undoFileTemplate();
},
},
};
</script>
<template>
<div class="d-flex align-items-center ide-file-templates">
<strong class="append-right-default">
{{ __('File templates') }}
</strong>
<dropdown
:data="templateTypes"
:label="selectedTemplateType.name || __('Choose a type...')"
class="mr-2"
@click="selectTemplateType"
/>
<dropdown
v-if="showTemplatesDropdown"
:label="__('Choose a template...')"
:is-async-data="true"
:searchable="true"
:title="__('File templates')"
class="mr-2"
@click="selectTemplate"
/>
<transition name="fade">
<button
v-show="updateSuccess"
type="button"
class="btn btn-default"
@click="undo"
>
{{ __('Undo') }}
</button>
</transition>
</div>
</template>
<script>
import $ from 'jquery';
import { mapActions, mapState } from 'vuex';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
export default {
components: {
DropdownButton,
LoadingIcon,
},
props: {
data: {
type: Array,
required: false,
default: () => [],
},
label: {
type: String,
required: true,
},
title: {
type: String,
required: false,
default: null,
},
isAsyncData: {
type: Boolean,
required: false,
default: false,
},
searchable: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
search: '',
};
},
computed: {
...mapState('fileTemplates', ['templates', 'isLoading']),
outputData() {
return (this.isAsyncData ? this.templates : this.data).filter(t => {
if (!this.searchable) return true;
return t.name.toLowerCase().indexOf(this.search.toLowerCase()) >= 0;
});
},
showLoading() {
return this.isAsyncData ? this.isLoading : false;
},
},
mounted() {
$(this.$el).on('show.bs.dropdown', this.fetchTemplatesIfAsync);
},
beforeDestroy() {
$(this.$el).off('show.bs.dropdown', this.fetchTemplatesIfAsync);
},
methods: {
...mapActions('fileTemplates', ['fetchTemplateTypes']),
fetchTemplatesIfAsync() {
if (this.isAsyncData) {
this.fetchTemplateTypes();
}
},
clickItem(item) {
this.$emit('click', item);
},
},
};
</script>
<template>
<div class="dropdown">
<dropdown-button
:toggle-text="label"
data-display="static"
/>
<div class="dropdown-menu pb-0">
<div
v-if="title"
class="dropdown-title ml-0 mr-0"
>
{{ title }}
</div>
<div
v-if="!showLoading && searchable"
class="dropdown-input"
>
<input
v-model="search"
:placeholder="__('Filter...')"
type="search"
class="dropdown-input-field"
/>
<i
aria-hidden="true"
class="fa fa-search dropdown-input-search"
></i>
</div>
<div class="dropdown-content">
<loading-icon
v-if="showLoading"
size="2"
/>
<ul v-else>
<li
v-for="(item, index) in outputData"
:key="index"
>
<button
type="button"
@click="clickItem(item)"
>
{{ item.name }}
</button>
</li>
</ul>
</div>
</div>
</div>
</template>
......@@ -10,6 +10,7 @@ import RepoEditor from './repo_editor.vue';
import FindFile from './file_finder/index.vue';
import RightPane from './panes/right.vue';
import ErrorMessage from './error_message.vue';
import CommitEditorHeader from './commit_sidebar/editor_header.vue';
const originalStopCallback = Mousetrap.stopCallback;
......@@ -23,6 +24,7 @@ export default {
FindFile,
RightPane,
ErrorMessage,
CommitEditorHeader,
},
computed: {
...mapState([
......@@ -34,7 +36,7 @@ export default {
'currentProjectId',
'errorMessage',
]),
...mapGetters(['activeFile', 'hasChanges', 'someUncommitedChanges']),
...mapGetters(['activeFile', 'hasChanges', 'someUncommitedChanges', 'isCommitModeActive']),
},
mounted() {
window.onbeforeunload = e => this.onBeforeUnload(e);
......@@ -96,7 +98,12 @@ export default {
<template
v-if="activeFile"
>
<commit-editor-header
v-if="isCommitModeActive"
:active-file="activeFile"
/>
<repo-tabs
v-else
:active-file="activeFile"
:files="openFiles"
:viewer="viewer"
......
<script>
import $ from 'jquery';
import { __ } from '~/locale';
import { mapActions, mapState } from 'vuex';
import { mapActions, mapState, mapGetters } from 'vuex';
import GlModal from '~/vue_shared/components/gl_modal.vue';
import { modalTypes } from '../../constants';
......@@ -15,6 +16,7 @@ export default {
},
computed: {
...mapState(['entryModal']),
...mapGetters('fileTemplates', ['templateTypes']),
entryName: {
get() {
if (this.entryModal.type === modalTypes.rename) {
......@@ -31,7 +33,9 @@ export default {
if (this.entryModal.type === modalTypes.tree) {
return __('Create new directory');
} else if (this.entryModal.type === modalTypes.rename) {
return this.entryModal.entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file');
return this.entryModal.entry.type === modalTypes.tree
? __('Rename folder')
: __('Rename file');
}
return __('Create new file');
......@@ -40,11 +44,16 @@ export default {
if (this.entryModal.type === modalTypes.tree) {
return __('Create directory');
} else if (this.entryModal.type === modalTypes.rename) {
return this.entryModal.entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file');
return this.entryModal.entry.type === modalTypes.tree
? __('Rename folder')
: __('Rename file');
}
return __('Create file');
},
isCreatingNew() {
return this.entryModal.type !== modalTypes.rename;
},
},
methods: {
...mapActions(['createTempEntry', 'renameEntry']),
......@@ -61,6 +70,14 @@ export default {
});
}
},
createFromTemplate(template) {
this.createTempEntry({
name: template.name,
type: this.entryModal.type,
});
$('#ide-new-entry').modal('toggle');
},
focusInput() {
this.$refs.fieldName.focus();
},
......@@ -77,6 +94,7 @@ export default {
:header-title-text="modalTitle"
:footer-primary-button-text="buttonLabel"
footer-primary-button-variant="success"
modal-size="lg"
@submit="submitForm"
@open="focusInput"
@closed="closedModal"
......@@ -84,16 +102,35 @@ export default {
<div
class="form-group row"
>
<label class="label-bold col-form-label col-sm-3">
<label class="label-bold col-form-label col-sm-2">
{{ __('Name') }}
</label>
<div class="col-sm-9">
<div class="col-sm-10">
<input
ref="fieldName"
v-model="entryName"
type="text"
class="form-control"
placeholder="/dir/file_name"
/>
<ul
v-if="isCreatingNew"
class="prepend-top-default list-inline"
>
<li
v-for="(template, index) in templateTypes"
:key="index"
class="list-inline-item"
>
<button
type="button"
class="btn btn-missing p-1 pr-2 pl-2"
@click="createFromTemplate(template)"
>
{{ template.name }}
</button>
</li>
</ul>
</div>
</div>
</gl-modal>
......
......@@ -95,8 +95,9 @@ export default {
:file-list="changedFiles"
:action-btn-text="__('Stage all changes')"
:active-file-key="activeFileKey"
:empty-state-text="__('There are no unstaged changes')"
action="stageAllChanges"
action-btn-icon="mobile-issue-close"
action-btn-icon="stage-all"
item-action-component="stage-button"
class="is-first"
icon-name="unstaged"
......@@ -108,8 +109,9 @@ export default {
:action-btn-text="__('Unstage all changes')"
:staged-list="true"
:active-file-key="activeFileKey"
:empty-state-text="__('There are no staged changes')"
action="unstageAllChanges"
action-btn-icon="history"
action-btn-icon="unstage-all"
item-action-component="unstage-button"
icon-name="staged"
/>
......
......@@ -6,12 +6,14 @@ 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';
import FileTemplatesBar from './file_templates/bar.vue';
export default {
components: {
ContentViewer,
DiffViewer,
ExternalLink,
FileTemplatesBar,
},
props: {
file: {
......@@ -34,6 +36,7 @@ export default {
'isCommitModeActive',
'isReviewModeActive',
]),
...mapGetters('fileTemplates', ['showFileTemplatesBar']),
shouldHideEditor() {
return this.file && this.file.binary && !this.file.content;
},
......@@ -216,7 +219,7 @@ export default {
id="ide"
class="blob-viewer-container blob-editor-container"
>
<div class="ide-mode-tabs clearfix" >
<div class="ide-mode-tabs clearfix">
<ul
v-if="!shouldHideEditor && isEditModeActive"
class="nav-links float-left"
......@@ -249,6 +252,9 @@ export default {
:file="file"
/>
</div>
<file-templates-bar
v-if="showFileTemplatesBar(file.name)"
/>
<div
v-show="!shouldHideEditor && file.viewMode ==='editor'"
ref="editor"
......
......@@ -4,6 +4,7 @@ import { visitUrl } from '~/lib/utils/url_utility';
import flash from '~/flash';
import * as types from './mutation_types';
import FilesDecoratorWorker from './workers/files_decorator_worker';
import { stageKeys } from '../constants';
export const redirectToUrl = (_, url) => visitUrl(url);
......@@ -122,14 +123,28 @@ export const scrollToTab = () => {
});
};
export const stageAllChanges = ({ state, commit }) => {
export const stageAllChanges = ({ state, commit, dispatch }) => {
const openFile = state.openFiles[0];
commit(types.SET_LAST_COMMIT_MSG, '');
state.changedFiles.forEach(file => commit(types.STAGE_CHANGE, file.path));
dispatch('openPendingTab', {
file: state.stagedFiles.find(f => f.path === openFile.path),
keyPrefix: stageKeys.staged,
});
};
export const unstageAllChanges = ({ state, commit }) => {
export const unstageAllChanges = ({ state, commit, dispatch }) => {
const openFile = state.openFiles[0];
state.stagedFiles.forEach(file => commit(types.UNSTAGE_CHANGE, file.path));
dispatch('openPendingTab', {
file: state.changedFiles.find(f => f.path === openFile.path),
keyPrefix: stageKeys.unstaged,
});
};
export const updateViewer = ({ commit }, viewer) => {
......@@ -206,6 +221,7 @@ export const resetOpenFiles = ({ commit }) => commit(types.RESET_OPEN_FILES);
export const renameEntry = ({ dispatch, commit, state }, { path, name, entryPath = null }) => {
const entry = state.entries[entryPath || path];
commit(types.RENAME_ENTRY, { path, name, entryPath });
if (entry.type === 'tree') {
......@@ -214,7 +230,7 @@ export const renameEntry = ({ dispatch, commit, state }, { path, name, entryPath
);
}
if (!entryPath) {
if (!entryPath && !entry.tempFile) {
dispatch('deleteEntry', path);
}
};
......
......@@ -5,7 +5,7 @@ import service from '../../services';
import * as types from '../mutation_types';
import router from '../../ide_router';
import { setPageTitle } from '../utils';
import { viewerTypes } from '../../constants';
import { viewerTypes, stageKeys } from '../../constants';
export const closeFile = ({ commit, state, dispatch }, file) => {
const { path } = file;
......@@ -208,8 +208,9 @@ export const discardFileChanges = ({ dispatch, state, commit, getters }, path) =
eventHub.$emit(`editor.update.model.dispose.unstaged-${file.key}`, file.content);
};
export const stageChange = ({ commit, state }, path) => {
export const stageChange = ({ commit, state, dispatch }, path) => {
const stagedFile = state.stagedFiles.find(f => f.path === path);
const openFile = state.openFiles.find(f => f.path === path);
commit(types.STAGE_CHANGE, path);
commit(types.SET_LAST_COMMIT_MSG, '');
......@@ -217,21 +218,39 @@ export const stageChange = ({ commit, state }, path) => {
if (stagedFile) {
eventHub.$emit(`editor.update.model.new.content.staged-${stagedFile.key}`, stagedFile.content);
}
if (openFile && openFile.active) {
const file = state.stagedFiles.find(f => f.path === path);
dispatch('openPendingTab', {
file,
keyPrefix: stageKeys.staged,
});
}
};
export const unstageChange = ({ commit }, path) => {
export const unstageChange = ({ commit, dispatch, state }, path) => {
const openFile = state.openFiles.find(f => f.path === path);
commit(types.UNSTAGE_CHANGE, path);
if (openFile && openFile.active) {
const file = state.changedFiles.find(f => f.path === path);
dispatch('openPendingTab', {
file,
keyPrefix: stageKeys.unstaged,
});
}
};
export const openPendingTab = ({ commit, getters, dispatch, state }, { file, keyPrefix }) => {
export const openPendingTab = ({ commit, getters, state }, { file, keyPrefix }) => {
if (getters.activeFile && getters.activeFile.key === `${keyPrefix}-${file.key}`) return false;
state.openFiles.forEach(f => eventHub.$emit(`editor.update.model.dispose.${f.key}`));
commit(types.ADD_PENDING_TAB, { file, keyPrefix });
dispatch('scrollToTab');
router.push(`/project/${file.projectId}/tree/${state.currentBranchId}/`);
return true;
......
......@@ -8,6 +8,7 @@ import commitModule from './modules/commit';
import pipelines from './modules/pipelines';
import mergeRequests from './modules/merge_requests';
import branches from './modules/branches';
import fileTemplates from './modules/file_templates';
Vue.use(Vuex);
......@@ -22,6 +23,7 @@ export const createStore = () =>
pipelines,
mergeRequests,
branches,
fileTemplates: fileTemplates(),
},
});
......
import Api from '~/api';
import { __ } from '~/locale';
import * as types from './mutation_types';
import eventHub from '../../../eventhub';
export const requestTemplateTypes = ({ commit }) => commit(types.REQUEST_TEMPLATE_TYPES);
export const receiveTemplateTypesError = ({ commit, dispatch }) => {
......@@ -31,9 +32,23 @@ export const fetchTemplateTypes = ({ dispatch, state }) => {
.catch(() => dispatch('receiveTemplateTypesError'));
};
export const setSelectedTemplateType = ({ commit }, type) =>
export const setSelectedTemplateType = ({ commit, dispatch, rootGetters }, type) => {
commit(types.SET_SELECTED_TEMPLATE_TYPE, type);
if (rootGetters.activeFile.prevPath === type.name) {
dispatch('discardFileChanges', rootGetters.activeFile.path, { root: true });
} else if (rootGetters.activeFile.name !== type.name) {
dispatch(
'renameEntry',
{
path: rootGetters.activeFile.path,
name: type.name,
},
{ root: true },
);
}
};
export const receiveTemplateError = ({ dispatch }, template) => {
dispatch(
'setErrorMessage',
......@@ -69,6 +84,7 @@ export const setFileTemplate = ({ dispatch, commit, rootGetters }, template) =>
{ root: true },
);
commit(types.SET_UPDATE_SUCCESS, true);
eventHub.$emit(`editor.update.model.new.content.${rootGetters.activeFile.key}`, template.content);
};
export const undoFileTemplate = ({ dispatch, commit, rootGetters }) => {
......@@ -76,6 +92,12 @@ export const undoFileTemplate = ({ dispatch, commit, rootGetters }) => {
dispatch('changeFileContent', { path: file.path, content: file.raw }, { root: true });
commit(types.SET_UPDATE_SUCCESS, false);
eventHub.$emit(`editor.update.model.new.content.${file.key}`, file.raw);
if (file.prevPath) {
dispatch('discardFileChanges', file.path, { root: true });
}
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
......
import { activityBarViews } from '../../../constants';
export const templateTypes = () => [
{
name: '.gitlab-ci.yml',
......@@ -17,7 +19,8 @@ export const templateTypes = () => [
},
];
export const showFileTemplatesBar = (_, getters) => name =>
getters.templateTypes.find(t => t.name === name);
export const showFileTemplatesBar = (_, getters, rootState) => name =>
getters.templateTypes.find(t => t.name === name) &&
rootState.currentActivityView === activityBarViews.edit;
export default () => {};
......@@ -3,10 +3,10 @@ import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
export default {
export default () => ({
namespaced: true,
actions,
state: createState(),
getters,
mutations,
};
});
import Vue from 'vue';
import * as types from './mutation_types';
import projectMutations from './mutations/project';
import mergeRequestMutation from './mutations/merge_request';
......@@ -226,7 +227,7 @@ export default {
path: newPath,
name: entryPath ? oldEntry.name : name,
tempFile: true,
prevPath: oldEntry.path,
prevPath: oldEntry.tempFile ? null : oldEntry.path,
url: oldEntry.url.replace(new RegExp(`${oldEntry.path}/?$`), newPath),
tree: [],
parentPath,
......@@ -245,6 +246,20 @@ export default {
if (newEntry.type === 'blob') {
state.changedFiles = state.changedFiles.concat(newEntry);
}
if (state.entries[newPath].opened) {
state.openFiles.push(state.entries[newPath]);
}
if (oldEntry.tempFile) {
const filterMethod = f => f.path !== oldEntry.path;
state.openFiles = state.openFiles.filter(filterMethod);
state.changedFiles = state.changedFiles.filter(filterMethod);
parent.tree = parent.tree.filter(filterMethod);
Vue.delete(state.entries, oldEntry.path);
}
},
...projectMutations,
...mergeRequestMutation,
......
......@@ -55,7 +55,7 @@ export default {
f => f.path === file.path && f.pending && !(f.tempFile && !f.prevPath),
);
if (file.tempFile) {
if (file.tempFile && file.content === '') {
Object.assign(state.entries[file.path], {
content: raw,
});
......
......@@ -82,11 +82,12 @@ export default {
value: 0,
},
currentXCoordinate: 0,
currentCoordinates: [],
currentCoordinates: {},
showFlag: false,
showFlagContent: false,
timeSeries: [],
realPixelRatio: 1,
seriesUnderMouse: [],
};
},
computed: {
......@@ -126,6 +127,9 @@ export default {
this.draw();
},
methods: {
showDot(path) {
return this.showFlagContent && this.seriesUnderMouse.includes(path);
},
draw() {
const breakpointSize = bp.getBreakpointSize();
const query = this.graphData.queries[0];
......@@ -155,7 +159,24 @@ export default {
point.y = e.clientY;
point = point.matrixTransform(this.$refs.graphData.getScreenCTM().inverse());
point.x += 7;
const firstTimeSeries = this.timeSeries[0];
this.seriesUnderMouse = this.timeSeries.filter((series) => {
const mouseX = series.timeSeriesScaleX.invert(point.x);
let minDistance = Infinity;
const closestTickMark = Object.keys(this.allXAxisValues).reduce((closest, x) => {
const distance = Math.abs(Number(new Date(x)) - Number(mouseX));
if (distance < minDistance) {
minDistance = distance;
return x;
}
return closest;
});
return series.values.find(v => v.time.toString() === closestTickMark);
});
const firstTimeSeries = this.seriesUnderMouse[0];
const timeValueOverlay = firstTimeSeries.timeSeriesScaleX.invert(point.x);
const overlayIndex = bisectDate(firstTimeSeries.values, timeValueOverlay, 1);
const d0 = firstTimeSeries.values[overlayIndex - 1];
......@@ -190,6 +211,17 @@ export default {
axisXScale.domain(d3.extent(allValues, d => d.time));
axisYScale.domain([0, d3.max(allValues.map(d => d.value))]);
this.allXAxisValues = this.timeSeries.reduce((obj, series) => {
const seriesKeys = {};
series.values.forEach(v => {
seriesKeys[v.time] = true;
});
return {
...obj,
...seriesKeys,
};
}, {});
const xAxis = d3
.axisBottom()
.scale(axisXScale)
......@@ -277,9 +309,8 @@ export default {
:line-style="path.lineStyle"
:line-color="path.lineColor"
:area-color="path.areaColor"
:current-coordinates="currentCoordinates[index]"
:current-time-series-index="index"
:show-dot="showFlagContent"
:current-coordinates="currentCoordinates[path.metricTag]"
:show-dot="showDot(path)"
/>
<graph-deployment
:deployment-data="reducedDeploymentData"
......@@ -303,7 +334,7 @@ export default {
:graph-height="graphHeight"
:graph-height-offset="graphHeightOffset"
:show-flag-content="showFlagContent"
:time-series="timeSeries"
:time-series="seriesUnderMouse"
:unit-of-display="unitOfDisplay"
:legend-title="legendTitle"
:deployment-flag-data="deploymentFlagData"
......
......@@ -52,7 +52,7 @@ export default {
required: true,
},
currentCoordinates: {
type: Array,
type: Object,
required: true,
},
},
......@@ -91,8 +91,8 @@ export default {
},
methods: {
seriesMetricValue(seriesIndex, series) {
const indexFromCoordinates = this.currentCoordinates[seriesIndex]
? this.currentCoordinates[seriesIndex].currentDataIndex : 0;
const indexFromCoordinates = this.currentCoordinates[series.metricTag]
? this.currentCoordinates[series.metricTag].currentDataIndex : 0;
const index = this.deploymentFlagData
? this.deploymentFlagData.seriesIndex
: indexFromCoordinates;
......
......@@ -50,19 +50,24 @@ const mixins = {
},
positionFlag() {
const timeSeries = this.timeSeries[0];
const hoveredDataIndex = bisectDate(timeSeries.values, this.hoverData.hoveredDate, 1);
const timeSeries = this.seriesUnderMouse[0];
if (!timeSeries) {
return;
}
const hoveredDataIndex = bisectDate(timeSeries.values, this.hoverData.hoveredDate);
this.currentData = timeSeries.values[hoveredDataIndex];
this.currentXCoordinate = Math.floor(timeSeries.timeSeriesScaleX(this.currentData.time));
this.currentCoordinates = this.timeSeries.map((series) => {
const currentDataIndex = bisectDate(series.values, this.hoverData.hoveredDate, 1);
this.currentCoordinates = {};
this.seriesUnderMouse.forEach((series) => {
const currentDataIndex = bisectDate(series.values, this.hoverData.hoveredDate);
const currentData = series.values[currentDataIndex];
const currentX = Math.floor(series.timeSeriesScaleX(currentData.time));
const currentY = Math.floor(series.timeSeriesScaleY(currentData.value));
return {
this.currentCoordinates[series.metricTag] = {
currentX,
currentY,
currentDataIndex,
......
......@@ -2,7 +2,7 @@ import _ from 'underscore';
import { scaleLinear, scaleTime } from 'd3-scale';
import { line, area, curveLinear } from 'd3-shape';
import { extent, max, sum } from 'd3-array';
import { timeMinute } from 'd3-time';
import { timeMinute, timeSecond } from 'd3-time';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
const d3 = {
......@@ -14,6 +14,7 @@ const d3 = {
extent,
max,
timeMinute,
timeSecond,
sum,
};
......@@ -51,6 +52,24 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom
return defaultColorPalette[pick];
}
function findByDate(series, time) {
const val = series.find(v => Math.abs(d3.timeSecond.count(time, v.time)) < 60);
if (val) {
return val.value;
}
return NaN;
}
// The timeseries data may have gaps in it
// but we need a regularly-spaced set of time/value pairs
// this gives us a complete range of one minute intervals
// offset the same amount as the original data
const [minX, maxX] = xDom;
const offset = d3.timeMinute(minX) - Number(minX);
const datesWithoutGaps = d3.timeSecond.every(60)
.range(d3.timeMinute.offset(minX, -1), maxX)
.map(d => d - offset);
query.result.forEach((timeSeries, timeSeriesNumber) => {
let metricTag = '';
let lineColor = '';
......@@ -119,9 +138,14 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom
});
}
const values = datesWithoutGaps.map(time => ({
time,
value: findByDate(timeSeries.values, time),
}));
timeSeriesParsed.push({
linePath: lineFunction(timeSeries.values),
areaPath: areaFunction(timeSeries.values),
linePath: lineFunction(values),
areaPath: areaFunction(values),
timeSeriesScaleX,
timeSeriesScaleY,
values: timeSeries.values,
......
......@@ -166,6 +166,10 @@
@include btn-outline($white-light, $red-500, $red-500, $red-500, $white-light, $red-600, $red-600, $red-700);
}
&.btn-warning {
@include btn-outline($white-light, $orange-500, $orange-500, $orange-500, $white-light, $orange-600, $orange-600, $orange-700);
}
&.btn-primary,
&.btn-info {
@include btn-outline($white-light, $blue-500, $blue-500, $blue-500, $white-light, $blue-600, $blue-600, $blue-700);
......
......@@ -7,6 +7,8 @@ $ide-context-header-padding: 10px;
$ide-project-avatar-end: $ide-context-header-padding + 48px;
$ide-tree-padding: $gl-padding;
$ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
$ide-commit-row-height: 32px;
$ide-commit-header-height: 48px;
.project-refs-form,
.project-refs-target-form {
......@@ -567,24 +569,11 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
}
.multi-file-commit-panel-header {
display: flex;
align-items: center;
margin-bottom: 0;
height: $ide-commit-header-height;
border-bottom: 1px solid $white-dark;
padding: 12px 0;
}
.multi-file-commit-panel-header-title {
display: flex;
flex: 1;
align-items: center;
svg {
margin-right: $gl-btn-padding;
color: $theme-gray-700;
}
}
.multi-file-commit-panel-collapse-btn {
border-left: 1px solid $white-dark;
margin-left: auto;
......@@ -594,8 +583,6 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
flex: 1;
overflow: auto;
padding: $grid-size 0;
margin-left: -$grid-size;
margin-right: -$grid-size;
min-height: 60px;
&.form-text.text-muted {
......@@ -660,6 +647,8 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
.multi-file-commit-list-path {
cursor: pointer;
height: $ide-commit-row-height;
padding-right: 0;
&.is-active {
background-color: $white-normal;
......@@ -668,6 +657,12 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
&:hover,
&:focus {
outline: 0;
.multi-file-discard-btn {
> .btn {
display: flex;
}
}
}
svg {
......@@ -679,6 +674,7 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
.multi-file-commit-list-file-path {
@include str-truncated(calc(100% - 30px));
user-select: none;
&:active {
text-decoration: none;
......@@ -686,9 +682,11 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
}
.multi-file-discard-btn {
top: 4px;
right: 8px;
bottom: 4px;
> .btn {
display: none;
width: $ide-commit-row-height;
height: $ide-commit-row-height;
}
svg {
top: 0;
......@@ -807,10 +805,9 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
}
.ide-staged-action-btn {
width: 22px;
margin-left: -1px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
width: $ide-commit-row-height;
height: $ide-commit-row-height;
color: inherit;
> svg {
top: 0;
......@@ -1442,3 +1439,29 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
top: 50%;
transform: translateY(-50%);
}
.ide-file-templates {
padding: $grid-size $gl-padding;
background-color: $gray-light;
border-bottom: 1px solid $white-dark;
.dropdown {
min-width: 180px;
}
.dropdown-content {
max-height: 222px;
}
}
.ide-commit-editor-header {
height: 65px;
padding: 8px 16px;
background-color: $theme-gray-50;
box-shadow: inset 0 -1px $white-dark;
}
.ide-commit-list-changed-icon {
width: $ide-commit-row-height;
height: $ide-commit-row-height;
}
......@@ -14,7 +14,8 @@ class Admin::LogsController < Admin::ApplicationController
Gitlab::GitLogger,
Gitlab::EnvironmentLogger,
Gitlab::SidekiqLogger,
Gitlab::RepositoryCheckLogger
Gitlab::RepositoryCheckLogger,
Gitlab::ProjectServiceLogger
]
end
end
......@@ -101,6 +101,7 @@ class ProfilesController < Profiles::ApplicationController
:organization,
:preferred_language,
:private_profile,
:include_private_contributions,
status: [:emoji, :message]
)
end
......
......@@ -21,6 +21,8 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
def render_diffs
@environment = @merge_request.environments_for(current_user).last
@diffs.write_cache
render json: DiffsSerializer.new(current_user: current_user).represent(@diffs, additional_attributes)
end
......
......@@ -48,20 +48,6 @@ class UserRecentEventsFinder
end
def projects
# Compile a list of projects `current_user` interacted with
# and `target_user` is allowed to see.
authorized = target_user
.project_interactions
.joins(:project_authorizations)
.where(project_authorizations: { user: current_user })
.select(:id)
visible = target_user
.project_interactions
.where(visibility_level: Gitlab::VisibilityLevel.levels_for_user(current_user))
.select(:id)
Gitlab::SQL::Union.new([authorized, visible]).to_sql
target_user.project_interactions.to_sql
end
end
......@@ -19,7 +19,7 @@ module EventsHelper
name = self_added ? 'You' : author.name
link_to name, user_path(author.username), title: name
else
event.author_name
escape_once(event.author_name)
end
end
......
......@@ -111,6 +111,10 @@ module Issuable
def allows_multiple_assignees?
false
end
def has_multiple_assignees?
assignees.count > 1
end
end
class_methods do
......
module ProjectServicesLoggable
def log_info(message, params = {})
message = build_message(message, params)
logger.info(message)
end
def log_error(message, params = {})
message = build_message(message, params)
logger.error(message)
end
def build_message(message, params = {})
{
service_class: self.class.name,
project_id: project.id,
project_path: project.full_path,
message: message
}.merge(params)
end
def logger
Gitlab::ProjectServiceLogger
end
end
......@@ -157,15 +157,17 @@ class Event < ActiveRecord::Base
if push? || commit_note?
Ability.allowed?(user, :download_code, project)
elsif membership_changed?
true
Ability.allowed?(user, :read_project, project)
elsif created_project?
true
Ability.allowed?(user, :read_project, project)
elsif issue? || issue_note?
Ability.allowed?(user, :read_issue, note? ? note_target : target)
elsif merge_request? || merge_request_note?
Ability.allowed?(user, :read_merge_request, note? ? note_target : target)
elsif milestone?
Ability.allowed?(user, :read_project, project)
else
milestone?
false # No other event types are visible
end
end
......
......@@ -101,7 +101,7 @@ http://app.asana.com/-/account_api'
task.update(completed: true)
end
rescue => e
Rails.logger.error(e.message)
log_error(e.message)
next
end
end
......
......@@ -104,7 +104,7 @@ class IrkerService < Service
new_recipient = URI.join(default_irc_uri, '/', recipient).to_s
uri = consider_uri(URI.parse(new_recipient))
rescue
Rails.logger.error("Unable to create a valid URL from #{default_irc_uri} and #{recipient}")
log_error("Unable to create a valid URL", default_irc_uri: default_irc_uri, recipient: recipient)
end
end
......
......@@ -92,7 +92,7 @@ class IssueTrackerService < Service
rescue Gitlab::HTTP::Error, Timeout::Error, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED, OpenSSL::SSL::SSLError => error
message = "#{self.type} had an error when trying to connect to #{self.project_url}: #{error.message}"
end
Rails.logger.info(message)
log_info(message)
result
end
......
......@@ -205,7 +205,7 @@ class JiraService < IssueTrackerService
begin
issue.transitions.build.save!(transition: { id: transition_id })
rescue => error
Rails.logger.info "#{self.class.name} Issue Transition failed message ERROR: #{client_url} - #{error.message}"
log_error("Issue transition failed", error: error.message, client_url: client_url)
return false
end
end
......@@ -257,9 +257,8 @@ class JiraService < IssueTrackerService
new_remote_link.save!(remote_link_props)
end
result_message = "#{self.class.name} SUCCESS: Successfully posted to #{client_url}."
Rails.logger.info(result_message)
result_message
log_info("Successfully posted", client_url: client_url)
"SUCCESS: Successfully posted to http://jira.example.net."
end
end
......@@ -317,7 +316,7 @@ class JiraService < IssueTrackerService
rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => e
@error = e.message
Rails.logger.info "#{self.class.name} Send message ERROR: #{client_url} - #{@error}"
log_error("Error sending message", client_url: client_url, error: @error)
nil
end
......
......@@ -6,6 +6,7 @@ class Service < ActiveRecord::Base
prepend EE::Service
include Sortable
include Importable
include ProjectServicesLoggable
serialize :properties, JSON # rubocop:disable Cop/ActiveRecordSerialize
......
......@@ -30,7 +30,7 @@ module MergeRequests
def clear_cache(new_diff)
# Executing the iteration we cache highlighted diffs for each diff file of
# MergeRequestDiff.
new_diff.diffs_collection.diff_files.to_a
new_diff.diffs_collection.write_cache
# Remove cache for all diffs on this MR. Do not use the association on the
# model, as that will interfere with other actions happening when
......@@ -38,7 +38,7 @@ module MergeRequests
MergeRequestDiff.where(merge_request: merge_request).each do |merge_request_diff|
next if merge_request_diff == new_diff
merge_request_diff.diffs_collection.clear_cache!
merge_request_diff.diffs_collection.clear_cache
end
end
end
......
......@@ -11,7 +11,7 @@ module Wikis
def initialize(*args)
super
@file_name = truncate_file_name(params[:file_name])
@file_name = clean_file_name(params[:file_name])
@file_path = File.join(ATTACHMENT_PATH, SecureRandom.hex, @file_name) if @file_name
@commit_message ||= "Upload attachment #{@file_name}"
@branch_name ||= wiki.default_branch
......@@ -23,8 +23,16 @@ module Wikis
private
def truncate_file_name(file_name)
def clean_file_name(file_name)
return unless file_name.present?
file_name = truncate_file_name(file_name)
# CommonMark does not allow Urls with whitespaces, so we have to replace them
# Using the same regex Carrierwave use to replace invalid characters
file_name.gsub(CarrierWave::SanitizedFile.sanitize_regexp, '_')
end
def truncate_file_name(file_name)
return file_name if file_name.length <= MAX_FILENAME_LENGTH
extension = File.extname(file_name)
......
......@@ -6,8 +6,15 @@ class NamespaceFileUploader < FileUploader
options.storage_path
end
def self.base_dir(model, _store = nil)
File.join(options.base_dir, 'namespace', model_path_segment(model))
def self.base_dir(model, store = nil)
base_dirs(model)[store || Store::LOCAL]
end
def self.base_dirs(model)
{
Store::LOCAL => File.join(options.base_dir, 'namespace', model_path_segment(model)),
Store::REMOTE => File.join('namespace', model_path_segment(model))
}
end
def self.model_path_segment(model)
......@@ -18,11 +25,4 @@ class NamespaceFileUploader < FileUploader
def store_dir
store_dirs[object_store]
end
def store_dirs
{
Store::LOCAL => File.join(base_dir, dynamic_segment),
Store::REMOTE => File.join('namespace', self.class.model_path_segment(model), dynamic_segment)
}
end
end
......@@ -11,3 +11,5 @@
= render "events/event/note", event: event
- else
= render "events/event/common", event: event
- elsif @user.include_private_contributions?
= render "events/event/private", event: event
%span.event-scope
= event_preposition(event)
- if event.project
= link_to_project event.project
= link_to_project(event.project)
- else
= event.project_name
= icon_for_profile_event(event)
.event-title
%span.author_name= link_to_author event
%span.author_name= link_to_author(event)
%span{ class: event.action_name }
- if event.target
= event.action_name
......
= icon_for_profile_event(event)
.event-title
%span.author_name= link_to_author event
%span.author_name= link_to_author(event)
%span{ class: event.action_name }
= event_action_name(event)
- if event.project
= link_to_project event.project
= link_to_project(event.project)
- else
= event.project_name
= icon_for_profile_event(event)
.event-title
%span.author_name= link_to_author event
%span.author_name= link_to_author(event)
= event.action_name
= event_note_title_html(event)
......
.event-inline.event-item
.event-item-timestamp
= time_ago_with_tooltip(event.created_at)
.system-note-image= sprite_icon('eye-slash', size: 16, css_class: 'icon')
.event-title
- author_name = capture do
%span.author_name= link_to_author(event)
= s_('Profiles|%{author_name} made a private contribution').html_safe % { author_name: author_name }
......@@ -3,7 +3,7 @@
= icon_for_profile_event(event)
.event-title
%span.author_name= link_to_author event
%span.author_name= link_to_author(event)
%span.pushed #{event.action_name} #{event.ref_type}
%strong
- commits_link = project_commits_path(project, event.ref_name)
......
- breadcrumb_title "Edit Profile"
- breadcrumb_title s_("Profiles|Edit Profile")
- @content_class = "limit-container-width" unless fluid_layout
- gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host
= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit' }, authenticity_token: true do |f|
= form_errors(@user)
......@@ -7,34 +8,36 @@
.row
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
Public Avatar
= s_("Profiles|Public Avatar")
%p
- if @user.avatar?
You can change your avatar here
- if gravatar_enabled?
or remove the current avatar to revert to #{link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host}
= s_("Profiles|You can change your avatar here or remove the current avatar to revert to %{gravatar_link}").html_safe % { gravatar_link: gravatar_link }
- else
= s_("Profiles|You can change your avatar here")
- else
You can upload an avatar here
- if gravatar_enabled?
or change it at #{link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host}
= s_("Profiles|You can upload your avatar here or change it at %{gravatar_link}").html_safe % { gravatar_link: gravatar_link }
- else
= s_("Profiles|You can upload your avatar here")
.col-lg-8
.clearfix.avatar-image.append-bottom-default
= link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
= image_tag avatar_icon_for_user(@user, 160), alt: '', class: 'avatar s160'
%h5.prepend-top-0= _("Upload new avatar")
%h5.prepend-top-0= s_("Profiles|Upload new avatar")
.prepend-top-5.append-bottom-10
%button.btn.js-choose-user-avatar-button{ type: 'button' }= _("Choose file...")
%span.avatar-file-name.prepend-left-default.js-avatar-filename= _("No file chosen")
%button.btn.js-choose-user-avatar-button{ type: 'button' }= s_("Profiles|Choose file...")
%span.avatar-file-name.prepend-left-default.js-avatar-filename= s_("Profiles|No file chosen")
= f.file_field_without_bootstrap :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*'
.form-text.text-muted= _("The maximum file size allowed is 200KB.")
.form-text.text-muted= s_("Profiles|The maximum file size allowed is 200KB.")
- if @user.avatar?
%hr
= link_to _('Remove avatar'), profile_avatar_path, data: { confirm: _('Avatar will be removed. Are you sure?') }, method: :delete, class: 'btn btn-danger btn-inverted'
= link_to s_("Profiles|Remove avatar"), profile_avatar_path, data: { confirm: s_("Profiles|Avatar will be removed. Are you sure?") }, method: :delete, class: 'btn btn-danger btn-inverted'
%hr
.row
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0= s_("User|Current status")
%h4.prepend-top-0= s_("Profiles|Current status")
%p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.")
.col-lg-8
= f.fields_for :status, @user.status do |status_form|
......@@ -66,62 +69,66 @@
.row
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
Main settings
= s_("Profiles|Main settings")
%p
This information will appear on your profile.
= s_("Profiles|This information will appear on your profile.")
- if current_user.ldap_user?
Some options are unavailable for LDAP accounts
= s_("Profiles|Some options are unavailable for LDAP accounts")
.col-lg-8
.row
- if @user.read_only_attribute?(:name)
= f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9' },
help: "Your name was automatically set based on your #{ attribute_provider_label(:name) } account, so people you know can recognize you."
help: s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you.") % { provider_label: attribute_provider_label(:name) }
- else
= f.text_field :name, label: 'Full name', required: true, wrapper: { class: 'col-md-9' }, help: "Enter your name, so people you know can recognize you."
= f.text_field :id, readonly: true, label: 'User ID', wrapper: { class: 'col-md-3' }
- if @user.read_only_attribute?(:email)
= f.text_field :email, required: true, readonly: true, help: "Your email address was automatically set based on your #{ attribute_provider_label(:email) } account."
= f.text_field :email, required: true, readonly: true, help: s_("Profiles|Your email address was automatically set based on your %{provider_label} account.") % { provider_label: attribute_provider_label(:email) }
- else
= f.text_field :email, required: true, value: (@user.email unless @user.temp_oauth_email?),
help: user_email_help_text(@user)
= f.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email),
{ help: 'This email will be displayed on your public profile.', include_blank: 'Do not show on profile' },
{ help: s_("Profiles|This email will be displayed on your public profile."), include_blank: s_("Profiles|Do not show on profile") },
control_class: 'select2'
= f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] },
{ help: 'This feature is experimental and translations are not complete yet.' },
{ help: s_("Profiles|This feature is experimental and translations are not complete yet.") },
control_class: 'select2'
= f.text_field :skype
= f.text_field :linkedin
= f.text_field :twitter
= f.text_field :website_url, label: 'Website'
= f.text_field :website_url, label: s_("Profiles|Website")
- if @user.read_only_attribute?(:location)
= f.text_field :location, readonly: true, help: "Your location was automatically set based on your #{ attribute_provider_label(:location) } account."
= f.text_field :location, readonly: true, help: s_("Profiles|Your location was automatically set based on your %{provider_label} account.") % { provider_label: attribute_provider_label(:location) }
- else
= f.text_field :location
= f.text_field :organization
= f.text_area :bio, rows: 4, maxlength: 250, help: 'Tell us about yourself in fewer than 250 characters.'
= f.text_area :bio, rows: 4, maxlength: 250, help: s_("Profiles|Tell us about yourself in fewer than 250 characters.")
%hr
%h5 Private profile
%h5= ("Private profile")
- private_profile_label = capture do
Don't display activity-related personal information on your profile
= s_("Profiles|Don't display activity-related personal information on your profiles")
= link_to icon('question-circle'), help_page_path('user/profile/index.md', anchor: 'private-profile')
= f.check_box :private_profile, label: private_profile_label
%h5= s_("Profiles|Private contributions")
= f.check_box :include_private_contributions, label: 'Include private contributions on my profile'
.help-block
= s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information.")
.prepend-top-default.append-bottom-default
= f.submit 'Update profile settings', class: 'btn btn-success'
= link_to 'Cancel', user_path(current_user), class: 'btn btn-cancel'
= f.submit s_("Profiles|Update profile settings"), class: 'btn btn-success'
= link_to _("Cancel"), user_path(current_user), class: 'btn btn-cancel'
.modal.modal-profile-crop
.modal-dialog
.modal-content
.modal-header
%h4.modal-title
Position and size your new avatar
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
= s_("Profiles|Position and size your new avatar")
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _("Close") }
%span{ "aria-hidden": true } &times;
.modal-body
.profile-crop-image-container
%img.modal-profile-crop-image{ alt: 'Avatar cropper' }
%img.modal-profile-crop-image{ alt: s_("Profiles|Avatar cropper") }
.crop-controls
.btn-group
%button.btn.btn-primary{ data: { method: 'zoom', option: '0.1' } }
......@@ -130,4 +137,4 @@
%span.fa.fa-search-minus
.modal-footer
%button.btn.btn-primary.js-upload-user-avatar{ type: 'button' }
Set new profile picture
= s_("Profiles|Set new profile picture")
......@@ -20,17 +20,17 @@
- if !membership_locked? && @project.allowed_to_share_with_group?
%ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' }
%li.nav-tab{ role: 'presentation' }
%a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' } Add member
%a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' } Invite member
%li.nav-tab{ role: 'presentation', class: ('active' if membership_locked?) }
%a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab' }, role: 'tab' } Invite group
.tab-content.gitlab-tab-content
.tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' }
= render 'projects/project_members/new_project_member', tab_title: 'Add member'
= render 'projects/project_members/new_project_member', tab_title: 'Invite member'
.tab-pane{ id: 'invite-group-pane', role: 'tabpanel', class: ('active' if membership_locked?) }
= render 'projects/project_members/new_project_group', tab_title: 'Invite group'
- elsif !membership_locked?
.invite-member= render 'projects/project_members/new_project_member', tab_title: 'Add member'
.invite-member= render 'projects/project_members/new_project_member', tab_title: 'Invite member'
- elsif @project.allowed_to_share_with_group?
.invite-group= render 'projects/project_members/new_project_group', tab_title: 'Invite group'
......
%h4.prepend-top-20
Contributions for
%strong= @calendar_date.to_s(:medium)
= _("Contributions for <strong>%{calendar_date}</strong>").html_safe % { calendar_date: @calendar_date.to_s(:medium) }
- if @events.any?
%ul.bordered-list
......@@ -9,6 +8,7 @@
%span.light
%i.fa.fa-clock-o
= event.created_at.strftime('%-I:%M%P')
- if event.visible_to_user?(current_user)
- if event.push?
#{event.action_name} #{event.ref_type}
%strong
......@@ -25,9 +25,11 @@
at
%strong
- if event.project
= link_to_project event.project
= link_to_project(event.project)
- else
= event.project_name
- else
made a private contribution
- else
%p
No contributions found for #{@calendar_date.to_s(:medium)}
= _('No contributions were found')
......@@ -9,6 +9,8 @@ class NewMergeRequestWorker
EventCreateService.new.open_mr(issuable, user)
NotificationService.new.new_merge_request(issuable, user)
issuable.diffs.write_cache
issuable.create_cross_references!(user)
end
......
---
title: Allow gaps in multiseries metrics charts
merge_request: 21427
author:
type: fixed
---
title: Include private contributions to contributions calendar
merge_request: 17296
author: George Tsiolis
type: added
---
title: Fix NamespaceUploader.base_dir for remote uploads
merge_request:
author:
type: fixed
---
title: Replace white spaces in wiki attachments file names
merge_request: 21569
author:
type: fixed
---
title: Improved commit panel in Web IDE
merge_request: 21471
author:
type: changed
---
title: Added file templates to the Web IDE
merge_request:
author:
type: added
---
title: Move project services log to a separate file
merge_request:
author:
type: other
---
title: Send max_patch_bytes to Gitaly via Gitaly::CommitDiffRequest
merge_request: 21575
author:
type: other
---
title: Write diff highlighting cache upon MR creation (refactors caching)
merge_request: 21489
author:
type: performance
---
title: Remove orphaned label links
merge_request: 21552
author:
type: fixed
---
title: Administrative cleanup rake tasks now leverage Gitaly
merge_request: 21588
author:
type: changed
......@@ -213,4 +213,3 @@
label: Pod average
unit: "cores"
track: canary
class AddIncludePrivateContributionsToUsers < ActiveRecord::Migration
DOWNTIME = false
def change
add_column :users, :include_private_contributions, :boolean
end
end
# frozen_string_literal: true
class RemoveOrphanedLabelLinks < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
class LabelLinks < ActiveRecord::Base
self.table_name = 'label_links'
include EachBatch
def self.orphaned
where('NOT EXISTS ( SELECT 1 FROM labels WHERE labels.id = label_links.label_id )')
end
end
def up
# Some of these queries can take up to 10 seconds to run on GitLab.com,
# which is pretty close to our 15 second statement timeout. To ensure a
# smooth deployment procedure we disable the statement timeouts for this
# migration, just in case.
disable_statement_timeout do
# On GitLab.com there are over 2,000,000 orphaned label links. On
# staging, removing 100,000 rows generated a max replication lag of 6.7
# MB. In total, removing all these rows will only generate about 136 MB
# of data, so it should be safe to do this.
LabelLinks.orphaned.each_batch(of: 100_000) do |batch|
batch.delete_all
end
end
add_concurrent_foreign_key(:label_links, :labels, column: :label_id, on_delete: :cascade)
end
def down
# There is no way to restore orphaned label links.
if foreign_key_exists?(:label_links, column: :label_id)
remove_foreign_key(:label_links, column: :label_id)
end
end
end
......@@ -2892,6 +2892,7 @@ ActiveRecord::Schema.define(version: 20180906101639) do
t.string "feed_token"
t.boolean "private_profile"
t.integer "roadmap_layout", limit: 2
t.boolean "include_private_contributions"
end
add_index "users", ["admin"], name: "index_users_on_admin", using: :btree
......@@ -3108,6 +3109,7 @@ ActiveRecord::Schema.define(version: 20180906101639) do
add_foreign_key "issues", "users", column: "author_id", name: "fk_05f1e72feb", on_delete: :nullify
add_foreign_key "issues", "users", column: "closed_by_id", name: "fk_c63cbf6c25", on_delete: :nullify
add_foreign_key "issues", "users", column: "updated_by_id", name: "fk_ffed080f01", on_delete: :nullify
add_foreign_key "label_links", "labels", name: "fk_d97dd08678", on_delete: :cascade
add_foreign_key "label_priorities", "labels", on_delete: :cascade
add_foreign_key "label_priorities", "projects", on_delete: :cascade
add_foreign_key "labels", "namespaces", column: "group_id", on_delete: :cascade
......
......@@ -113,6 +113,19 @@ October 07, 2014 11:25: User "Claudie Hodkiewicz" (nasir_stehr@olson.co.uk) was
October 07, 2014 11:25: Project "project133" was removed
```
## `integrations_json.log`
This file lives in `/var/log/gitlab/gitlab-rails/integrations_json.log` for
Omnibus GitLab packages or in `/home/git/gitlab/log/integrations_json.log` for
installations from source.
It contains information about [integrations](../user/project/integrations/project_services.md) activities such as JIRA, Asana and Irker services. It uses JSON format like the example below:
``` json
{"severity":"ERROR","time":"2018-09-06T14:56:20.439Z","service_class":"JiraService","project_id":8,"project_path":"h5bp/html5-boilerplate","message":"Error sending message","client_url":"http://jira.gitlap.com:8080","error":"execution expired"}
{"severity":"INFO","time":"2018-09-06T17:15:16.365Z","service_class":"JiraService","project_id":3,"project_path":"namespace2/project2","message":"Successfully posted","client_url":"http://jira.example.net"}
```
## `githost.log`
This file lives in `/var/log/gitlab/gitlab-rails/githost.log` for
......
......@@ -6,8 +6,7 @@ We strive to support the 2-4 most important metrics for each common system servi
### Query identifier
The requirement for adding a new metrics is to make each query to have an unique identifier.
Identifier is used to update the metric later when changed.
The requirement for adding a new metric is to make each query to have an unique identifier which is used to update the metric later when changed:
```yaml
- group: Response metrics (NGINX Ingress)
......@@ -25,9 +24,10 @@ Identifier is used to update the metric later when changed.
After you add or change existing _common_ metric you have to create a new database migration that will query and update all existing metrics.
**Note: If a query metric (which is identified by `id:`) is removed it will not be removed from database by default.**
**You might want to add additional database migration that makes a decision what to do with removed one.**
**For example: you might be interested in migrating all dependent data to a different metric.**
NOTE: **Note:**
If a query metric (which is identified by `id:`) is removed it will not be removed from database by default.
You might want to add additional database migration that makes a decision what to do with removed one.
For example: you might be interested in migrating all dependent data to a different metric.
```ruby
class ImportCommonMetrics < ActiveRecord::Migration
......
......@@ -91,6 +91,18 @@ To enable private profile:
NOTE: **Note:**
You and GitLab admins can see your the abovementioned information on your profile even if it is private.
## Private contributions
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/14078) in GitLab 11.3.
Enabling private contributions will include contributions to private projects, in the user contribution calendar graph and user recent activity.
To enable private contributions:
1. Navigate to your personal [profile settings](#profile-settings).
2. Check the "Private contributions" option.
3. Hit **Update profile settings**.
## Current status
> Introduced in GitLab 11.2.
......
......@@ -78,13 +78,14 @@ switching to a different branch.
The Web IDE can be used to preview JavaScript projects right in the browser.
This feature uses CodeSandbox to compile and bundle the JavaScript used to
preview the web application. On public projects, an `Open in CodeSandbox`
button is visible which will transfer the contents of the project into a
CodeSandbox project to share with others.
**Note** this button is not visible on private or internal projects.
preview the web application.
![Web IDE Client Side Evaluation](img/clientside_evaluation.png)
Additionally, for public projects an `Open in CodeSandbox` button is available
to transfer the contents of the project into a public CodeSandbox project to
quickly share your project with others.
### Enabling Client Side Evaluation
The Client Side Evaluation feature needs to be enabled in the GitLab instances
......
require 'spec_helper'
describe 'Project > Members > Invite Group', :js do
describe 'Project > Members > Invite group and members', :js do
include Select2Helper
include ActionView::Helpers::DateHelper
let(:maintainer) { create(:user) }
describe 'Invite group lock' do
describe 'Share group lock' do
shared_examples 'the project cannot be shared with groups' do
it 'user is only able to share with members' do
visit project_settings_members_path(project)
......@@ -189,95 +189,4 @@ describe 'Project > Members > Invite Group', :js do
end
end
end
describe 'setting an expiration date for a group link' do
let(:project) { create(:project) }
let!(:group) { create(:group) }
around do |example|
Timecop.freeze { example.run }
end
before do
project.add_maintainer(maintainer)
sign_in(maintainer)
visit project_settings_members_path(project)
click_on 'invite-group-tab'
select2 group.id, from: '#link_group_id'
fill_in 'expires_at_groups', with: (Time.now + 4.5.days).strftime('%Y-%m-%d')
click_on 'invite-group-tab'
find('.btn-create').click
end
it 'the group link shows the expiration time with a warning class' do
page.within('.project-members-groups') do
# Using distance_of_time_in_words_to_now because it is not the same as
# subtraction, and this way avoids time zone issues as well
expires_in_text = distance_of_time_in_words_to_now(project.project_group_links.first.expires_at)
expect(page).to have_content(expires_in_text)
expect(page).to have_selector('.text-warning')
end
end
end
describe 'the groups dropdown' do
context 'with multiple groups to choose from' do
let(:project) { create(:project) }
before do
project.add_maintainer(maintainer)
sign_in(maintainer)
create(:group).add_owner(maintainer)
create(:group).add_owner(maintainer)
visit project_settings_members_path(project)
click_link 'Invite group'
find('.ajax-groups-select.select2-container')
execute_script 'GROUP_SELECT_PER_PAGE = 1;'
open_select2 '#link_group_id'
end
it 'should infinitely scroll' do
expect(find('.select2-drop .select2-results')).to have_selector('.select2-result', count: 1)
scroll_select2_to_bottom('.select2-drop .select2-results:visible')
expect(find('.select2-drop .select2-results')).to have_selector('.select2-result', count: 2)
end
end
context 'for a project in a nested group' do
let(:group) { create(:group) }
let!(:nested_group) { create(:group, parent: group) }
let!(:group_to_share_with) { create(:group) }
let!(:project) { create(:project, namespace: nested_group) }
before do
project.add_maintainer(maintainer)
sign_in(maintainer)
group.add_maintainer(maintainer)
group_to_share_with.add_maintainer(maintainer)
end
it 'the groups dropdown does not show ancestors', :nested_groups do
visit project_settings_members_path(project)
click_on 'invite-group-tab'
click_link 'Search for a group'
page.within '.select2-drop' do
expect(page).to have_content(group_to_share_with.name)
expect(page).not_to have_content(group.name)
end
end
end
end
end
......@@ -8,8 +8,9 @@ module Banzai
#
# Based on Banzai::Filter::AutolinkFilter
#
# CommonMark does not allow spaces in the url portion of a link.
# For example, `[example](page slug)` is not valid. However,
# CommonMark does not allow spaces in the url portion of a link/url.
# For example, `[example](page slug)` is not valid.
# Neither is `![example](test image.jpg)`. However,
# in our wikis, we support (via RedCarpet) this type of link, allowing
# wiki pages to be easily linked by their title. This filter adds that functionality.
# The intent is for this to only be used in Wikis - in general, we want
......@@ -20,10 +21,17 @@ module Banzai
# Pattern to match a standard markdown link
#
# Rubular: http://rubular.com/r/z9EAHxYmKI
LINK_PATTERN = /\[([^\]]+)\]\(([^)"]+)(?: \"([^\"]+)\")?\)/
# Text matching LINK_PATTERN inside these elements will not be linked
# Rubular: http://rubular.com/r/2EXEQ49rg5
LINK_OR_IMAGE_PATTERN = %r{
(?<preview_operator>!)?
\[(?<text>.+?)\]
\(
(?<new_link>.+?)
(?<title>\ ".+?")?
\)
}x
# Text matching LINK_OR_IMAGE_PATTERN inside these elements will not be linked
IGNORE_PARENTS = %w(a code kbd pre script style).to_set
# The XPath query to use for finding text nodes to parse.
......@@ -38,7 +46,7 @@ module Banzai
doc.xpath(TEXT_QUERY).each do |node|
content = node.to_html
next unless content.match(LINK_PATTERN)
next unless content.match(LINK_OR_IMAGE_PATTERN)
html = spaced_link_filter(content)
......@@ -53,25 +61,37 @@ module Banzai
private
def spaced_link_match(link)
match = LINK_PATTERN.match(link)
return link unless match && match[1] && match[2]
match = LINK_OR_IMAGE_PATTERN.match(link)
return link unless match
# escape the spaces in the url so that it's a valid markdown link,
# then run it through the markdown processor again, let it do its magic
text = match[1]
new_link = match[2].gsub(' ', '%20')
title = match[3] ? " \"#{match[3]}\"" : ''
html = Banzai::Filter::MarkdownFilter.call("[#{text}](#{new_link}#{title})", context)
html = Banzai::Filter::MarkdownFilter.call(transform_markdown(match), context)
# link is wrapped in a <p>, so strip that off
html.sub('<p>', '').chomp('</p>')
end
def spaced_link_filter(text)
Gitlab::StringRegexMarker.new(CGI.unescapeHTML(text), text.html_safe).mark(LINK_PATTERN) do |link, left:, right:|
Gitlab::StringRegexMarker.new(CGI.unescapeHTML(text), text.html_safe).mark(LINK_OR_IMAGE_PATTERN) do |link, left:, right:|
spaced_link_match(link)
end
end
def transform_markdown(match)
preview_operator, text, new_link, title = process_match(match)
"#{preview_operator}[#{text}](#{new_link}#{title})"
end
def process_match(match)
[
match[:preview_operator],
match[:text],
match[:new_link].gsub(' ', '%20'),
match[:title]
]
end
end
end
end
......@@ -5,7 +5,7 @@ module Banzai
@filters ||= begin
super.insert_after(Filter::TableOfContentsFilter, Filter::GollumTagsFilter)
.insert_before(Filter::TaskListFilter, Filter::WikiLinkFilter)
.insert_before(Filter::WikiLinkFilter, Filter::SpacedLinkFilter)
.insert_before(Filter::VideoLinkFilter, Filter::SpacedLinkFilter)
end
end
end
......
......@@ -7,7 +7,11 @@ module Gitlab
def initialize(contributor, current_user = nil)
@contributor = contributor
@current_user = current_user
@projects = ContributedProjectsFinder.new(contributor).execute(current_user)
@projects = if @contributor.include_private_contributions?
ContributedProjectsFinder.new(@contributor).execute(@contributor)
else
ContributedProjectsFinder.new(contributor).execute(current_user)
end
end
def activity_dates
......@@ -36,13 +40,9 @@ module Gitlab
def events_by_date(date)
return Event.none unless can_read_cross_project?
events = Event.contributions.where(author_id: contributor.id)
Event.contributions.where(author_id: contributor.id)
.where(created_at: date.beginning_of_day..date.end_of_day)
.where(project_id: projects)
# Use visible_to_user? instead of the complicated logic in activity_dates
# because we're only viewing the events for a single day.
events.select { |event| event.visible_to_user?(current_user) }
end
def starting_year
......
......@@ -2,7 +2,7 @@ module Gitlab
module Diff
module FileCollection
class Base
attr_reader :project, :diff_options, :diff_refs, :fallback_diff_refs
attr_reader :project, :diff_options, :diff_refs, :fallback_diff_refs, :diffable
delegate :count, :size, :real_size, to: :diff_files
......@@ -33,6 +33,14 @@ module Gitlab
diff_files.find { |diff_file| diff_file.new_path == new_path }
end
def clear_cache
# No-op
end
def write_cache
# No-op
end
private
def decorate_diff!(diff)
......
......@@ -2,6 +2,8 @@ module Gitlab
module Diff
module FileCollection
class MergeRequestDiff < Base
extend ::Gitlab::Utils::Override
def initialize(merge_request_diff, diff_options:)
@merge_request_diff = merge_request_diff
......@@ -13,70 +15,35 @@ module Gitlab
end
def diff_files
# Make sure to _not_ send any method call to Gitlab::Diff::File
# _before_ all of them were collected (`super`). Premature method calls will
# trigger N+1 RPCs to Gitaly through BatchLoader records (Blob.lazy).
#
diff_files = super
diff_files.each { |diff_file| cache_highlight!(diff_file) if cacheable?(diff_file) }
store_highlight_cache
diff_files.each { |diff_file| cache.decorate(diff_file) }
diff_files
end
def real_size
@merge_request_diff.real_size
override :write_cache
def write_cache
cache.write_if_empty
end
def clear_cache!
Rails.cache.delete(cache_key)
override :clear_cache
def clear_cache
cache.clear
end
def cache_key
[@merge_request_diff, 'highlighted-diff-files', Gitlab::Diff::Line::SERIALIZE_KEYS, diff_options]
cache.key
end
private
def highlight_diff_file_from_cache!(diff_file, cache_diff_lines)
diff_file.highlighted_diff_lines = cache_diff_lines.map do |line|
Gitlab::Diff::Line.init_from_hash(line)
end
end
#
# If we find the highlighted diff files lines on the cache we replace existing diff_files lines (no highlighted)
# for the highlighted ones, so we just skip their execution.
# If the highlighted diff files lines are not cached we calculate and cache them.
#
# The content of the cache is a Hash where the key identifies the file and the values are Arrays of
# hashes that represent serialized diff lines.
#
def cache_highlight!(diff_file)
item_key = diff_file.file_identifier
if highlight_cache[item_key]
highlight_diff_file_from_cache!(diff_file, highlight_cache[item_key])
else
highlight_cache[item_key] = diff_file.highlighted_diff_lines.map(&:to_hash)
end
end
def highlight_cache
return @highlight_cache if defined?(@highlight_cache)
@highlight_cache = Rails.cache.read(cache_key) || {}
@highlight_cache_was_empty = @highlight_cache.empty?
@highlight_cache
def real_size
@merge_request_diff.real_size
end
def store_highlight_cache
Rails.cache.write(cache_key, highlight_cache, expires_in: 1.week) if @highlight_cache_was_empty
end
private
def cacheable?(diff_file)
@merge_request_diff.present? && diff_file.text? && diff_file.diffable?
def cache
@cache ||= Gitlab::Diff::HighlightCache.new(self)
end
end
end
......
# frozen_string_literal: true
#
module Gitlab
module Diff
class HighlightCache
delegate :diffable, to: :@diff_collection
delegate :diff_options, to: :@diff_collection
def initialize(diff_collection, backend: Rails.cache)
@backend = backend
@diff_collection = diff_collection
end
# - Reads from cache
# - Assigns DiffFile#highlighted_diff_lines for cached files
def decorate(diff_file)
if content = read_file(diff_file)
diff_file.highlighted_diff_lines = content.map do |line|
Gitlab::Diff::Line.init_from_hash(line)
end
end
end
# It populates a Hash in order to submit a single write to the memory
# cache. This avoids excessive IO generated by N+1's (1 writing for
# each highlighted line or file).
def write_if_empty
return if cached_content.present?
@diff_collection.diff_files.each do |diff_file|
next unless cacheable?(diff_file)
diff_file_id = diff_file.file_identifier
cached_content[diff_file_id] = diff_file.highlighted_diff_lines.map(&:to_hash)
end
cache.write(key, cached_content, expires_in: 1.week)
end
def clear
cache.delete(key)
end
def key
[diffable, 'highlighted-diff-files', Gitlab::Diff::Line::SERIALIZE_KEYS, diff_options]
end
private
def read_file(diff_file)
cached_content[diff_file.file_identifier]
end
def cache
@backend
end
def cached_content
@cached_content ||= cache.read(key) || {}
end
def cacheable?(diff_file)
diffable.present? && diff_file.text? && diff_file.diffable?
end
end
end
end
......@@ -11,7 +11,7 @@ module Gitlab
delegate :max_files, :max_lines, :max_bytes, :safe_max_files, :safe_max_lines, :safe_max_bytes, to: :limits
def self.collection_limits(options = {})
def self.limits(options = {})
limits = {}
limits[:max_files] = options.fetch(:max_files, DEFAULT_LIMITS[:max_files])
limits[:max_lines] = options.fetch(:max_lines, DEFAULT_LIMITS[:max_lines])
......@@ -19,13 +19,14 @@ module Gitlab
limits[:safe_max_files] = [limits[:max_files], DEFAULT_LIMITS[:max_files]].min
limits[:safe_max_lines] = [limits[:max_lines], DEFAULT_LIMITS[:max_lines]].min
limits[:safe_max_bytes] = limits[:safe_max_files] * 5.kilobytes # Average 5 KB per file
limits[:max_patch_bytes] = Gitlab::Git::Diff::SIZE_LIMIT
OpenStruct.new(limits)
end
def initialize(iterator, options = {})
@iterator = iterator
@limits = self.class.collection_limits(options)
@limits = self.class.limits(options)
@enforce_limits = !!options.fetch(:limits, true)
@expanded = !!options.fetch(:expanded, true)
......
......@@ -369,7 +369,7 @@ module Gitlab
request_params[:ignore_whitespace_change] = options.fetch(:ignore_whitespace_change, false)
request_params[:enforce_limits] = options.fetch(:limits, true)
request_params[:collapse_diffs] = !options.fetch(:expanded, true)
request_params.merge!(Gitlab::Git::DiffCollection.collection_limits(options).to_h)
request_params.merge!(Gitlab::Git::DiffCollection.limits(options).to_h)
request = Gitaly::CommitDiffRequest.new(request_params)
response = GitalyClient.call(@repository.storage, :diff_service, :commit_diff, request, timeout: GitalyClient.medium_timeout)
......
module Gitlab
module GitalyClient
class RemoteService
include Gitlab::EncodingHelper
MAX_MSG_SIZE = 128.kilobytes.freeze
def self.exists?(remote_url)
......@@ -61,7 +63,7 @@ module Gitlab
response = GitalyClient.call(@storage, :remote_service,
:find_remote_root_ref, request)
response.ref.presence
encode_utf8(response.ref)
end
def update_remote_mirror(ref_name, only_branches_matching)
......
......@@ -5,6 +5,14 @@ module Gitlab
@storage = storage
end
# Returns all directories in the git storage directory, lexically ordered
def list_directories(depth: 1)
request = Gitaly::ListDirectoriesRequest.new(storage_name: @storage, depth: depth)
GitalyClient.call(@storage, :storage_service, :list_directories, request)
.flat_map(&:paths)
end
# Delete all repositories in the storage. This is a slow and VERY DESTRUCTIVE operation.
def delete_all_repositories
request = Gitaly::DeleteAllRepositoriesRequest.new(storage_name: @storage)
......
module Gitlab
class ProjectServiceLogger < Gitlab::JsonLogger
def self.file_name_noext
'integrations_json'
end
end
end
# Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/954
#
# frozen_string_literal: true
require 'set'
namespace :gitlab do
namespace :cleanup do
HASHED_REPOSITORY_NAME = '@hashed'.freeze
desc "GitLab | Cleanup | Clean namespaces"
task dirs: :gitlab_environment do
warn_user_is_not_gitlab
namespaces = Set.new(Namespace.pluck(:path))
namespaces << Storage::HashedProject::ROOT_PATH_PREFIX
namespaces = Namespace.pluck(:path)
namespaces << HASHED_REPOSITORY_NAME # add so that it will be ignored
Gitlab.config.repositories.storages.each do |name, repository_storage|
git_base_path = Gitlab::GitalyClient::StorageSettings.allow_disk_access { repository_storage.legacy_disk_path }
all_dirs = Dir.glob(git_base_path + '/*')
Gitaly::Server.all.each do |server|
all_dirs = Gitlab::GitalyClient::StorageService
.new(server.storage)
.list_directories(depth: 0)
.reject { |dir| dir.ends_with?('.git') || namespaces.include?(File.basename(dir)) }
puts git_base_path.color(:yellow)
puts "Looking for directories to remove... "
all_dirs.reject! do |dir|
# skip if git repo
dir =~ /.git$/
end
all_dirs.reject! do |dir|
dir_name = File.basename dir
# skip if namespace present
namespaces.include?(dir_name)
end
all_dirs.each do |dir_path|
if remove?
if FileUtils.rm_rf dir_path
puts "Removed...#{dir_path}".color(:red)
else
puts "Cannot remove #{dir_path}".color(:red)
begin
Gitlab::GitalyClient::NamespaceService.new(server.storage)
.remove(dir_path)
puts "Removed...#{dir_path}"
rescue StandardError => e
puts "Cannot remove #{dir_path}: #{e.message}".color(:red)
end
else
puts "Can be removed: #{dir_path}".color(:red)
......@@ -79,29 +68,29 @@ namespace :gitlab do
desc "GitLab | Cleanup | Clean repositories"
task repos: :gitlab_environment do
warn_user_is_not_gitlab
move_suffix = "+orphaned+#{Time.now.to_i}"
Gitlab.config.repositories.storages.each do |name, repository_storage|
repo_root = Gitlab::GitalyClient::StorageSettings.allow_disk_access { repository_storage.legacy_disk_path }
# Look for global repos (legacy, depth 1) and normal repos (depth 2)
IO.popen(%W(find #{repo_root} -mindepth 1 -maxdepth 2 -name *.git)) do |find|
find.each_line do |path|
path.chomp!
repo_with_namespace = path
.sub(repo_root, '')
.sub(%r{^/*}, '')
.chomp('.git')
.chomp('.wiki')
Gitaly::Server.all.each do |server|
Gitlab::GitalyClient::StorageService
.new(server.storage)
.list_directories
.each do |path|
repo_with_namespace = path.chomp('.git').chomp('.wiki')
# TODO ignoring hashed repositories for now. But revisit to fully support
# possible orphaned hashed repos
next if repo_with_namespace.start_with?("#{HASHED_REPOSITORY_NAME}/") || Project.find_by_full_path(repo_with_namespace)
next if repo_with_namespace.start_with?(Storage::HashedProject::ROOT_PATH_PREFIX)
next if Project.find_by_full_path(repo_with_namespace)
new_path = path + move_suffix
puts path.inspect + ' -> ' + new_path.inspect
File.rename(path, new_path)
begin
Gitlab::GitalyClient::NamespaceService
.new(server.storage)
.rename(path, new_path)
rescue StandardError => e
puts "Error occured while moving the repository: #{e.message}".color(:red)
end
end
end
......
......@@ -1421,6 +1421,12 @@ msgstr ""
msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request."
msgstr ""
msgid "Choose a template..."
msgstr ""
msgid "Choose a type..."
msgstr ""
msgid "Choose any color."
msgstr ""
......@@ -2202,6 +2208,9 @@ msgstr ""
msgid "Contribution guide"
msgstr ""
msgid "Contributions for <strong>%{calendar_date}</strong>"
msgstr ""
msgid "Contributions per group member"
msgstr ""
......@@ -2672,9 +2681,21 @@ msgstr ""
msgid "Disable group Runners"
msgstr ""
msgid "Discard"
msgstr ""
msgid "Discard all changes"
msgstr ""
msgid "Discard all unstaged changes?"
msgstr ""
msgid "Discard changes"
msgstr ""
msgid "Discard changes to %{path}?"
msgstr ""
msgid "Discard draft"
msgstr ""
......@@ -3173,6 +3194,9 @@ msgstr ""
msgid "Fields on this page are now uneditable, you can configure"
msgstr ""
msgid "File templates"
msgstr ""
msgid "Files"
msgstr ""
......@@ -3188,6 +3212,9 @@ msgstr ""
msgid "Filter by commit message"
msgstr ""
msgid "Filter..."
msgstr ""
msgid "Find by path"
msgstr ""
......@@ -4825,9 +4852,6 @@ msgstr ""
msgid "More"
msgstr ""
msgid "More actions"
msgstr ""
msgid "More info"
msgstr ""
......@@ -4980,6 +5004,9 @@ msgstr ""
msgid "No container images stored for this project. Add one by following the instructions above."
msgstr ""
msgid "No contributions were found"
msgstr ""
msgid "No due date"
msgstr ""
......@@ -5588,6 +5615,9 @@ msgstr ""
msgid "Profiles| You are going to change the username %{currentUsernameBold} to %{newUsernameBold}. Profile and projects will be redirected to the %{newUsername} namespace but this redirect will expire once the %{currentUsername} namespace is registered by another user or group. Please update your Git repository remotes as soon as possible."
msgstr ""
msgid "Profiles|%{author_name} made a private contribution"
msgstr ""
msgid "Profiles|Account scheduled for removal."
msgstr ""
......@@ -5597,15 +5627,30 @@ msgstr ""
msgid "Profiles|Add status emoji"
msgstr ""
msgid "Profiles|Avatar cropper"
msgstr ""
msgid "Profiles|Avatar will be removed. Are you sure?"
msgstr ""
msgid "Profiles|Change username"
msgstr ""
msgid "Profiles|Choose file..."
msgstr ""
msgid "Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information."
msgstr ""
msgid "Profiles|Clear status"
msgstr ""
msgid "Profiles|Current path: %{path}"
msgstr ""
msgid "Profiles|Current status"
msgstr ""
msgid "Profiles|Delete Account"
msgstr ""
......@@ -5618,39 +5663,108 @@ msgstr ""
msgid "Profiles|Deleting an account has the following effects:"
msgstr ""
msgid "Profiles|Do not show on profile"
msgstr ""
msgid "Profiles|Don't display activity-related personal information on your profiles"
msgstr ""
msgid "Profiles|Edit Profile"
msgstr ""
msgid "Profiles|Invalid password"
msgstr ""
msgid "Profiles|Invalid username"
msgstr ""
msgid "Profiles|Main settings"
msgstr ""
msgid "Profiles|No file chosen"
msgstr ""
msgid "Profiles|Path"
msgstr ""
msgid "Profiles|Position and size your new avatar"
msgstr ""
msgid "Profiles|Private contributions"
msgstr ""
msgid "Profiles|Public Avatar"
msgstr ""
msgid "Profiles|Remove avatar"
msgstr ""
msgid "Profiles|Set new profile picture"
msgstr ""
msgid "Profiles|Some options are unavailable for LDAP accounts"
msgstr ""
msgid "Profiles|Tell us about yourself in fewer than 250 characters."
msgstr ""
msgid "Profiles|The maximum file size allowed is 200KB."
msgstr ""
msgid "Profiles|This doesn't look like a public SSH key, are you sure you want to add it?"
msgstr ""
msgid "Profiles|This email will be displayed on your public profile."
msgstr ""
msgid "Profiles|This emoji and message will appear on your profile and throughout the interface."
msgstr ""
msgid "Profiles|This feature is experimental and translations are not complete yet."
msgstr ""
msgid "Profiles|This information will appear on your profile."
msgstr ""
msgid "Profiles|Type your %{confirmationValue} to confirm:"
msgstr ""
msgid "Profiles|Typically starts with \"ssh-rsa …\""
msgstr ""
msgid "Profiles|Update profile settings"
msgstr ""
msgid "Profiles|Update username"
msgstr ""
msgid "Profiles|Upload new avatar"
msgstr ""
msgid "Profiles|Username change failed - %{message}"
msgstr ""
msgid "Profiles|Username successfully changed"
msgstr ""
msgid "Profiles|Website"
msgstr ""
msgid "Profiles|What's your status?"
msgstr ""
msgid "Profiles|You can change your avatar here"
msgstr ""
msgid "Profiles|You can change your avatar here or remove the current avatar to revert to %{gravatar_link}"
msgstr ""
msgid "Profiles|You can upload your avatar here"
msgstr ""
msgid "Profiles|You can upload your avatar here or change it at %{gravatar_link}"
msgstr ""
msgid "Profiles|You don't have access to delete this user."
msgstr ""
......@@ -5660,6 +5774,15 @@ msgstr ""
msgid "Profiles|Your account is currently an owner in these groups:"
msgstr ""
msgid "Profiles|Your email address was automatically set based on your %{provider_label} account."
msgstr ""
msgid "Profiles|Your location was automatically set based on your %{provider_label} account."
msgstr ""
msgid "Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you."
msgstr ""
msgid "Profiles|Your status"
msgstr ""
......@@ -7266,6 +7389,12 @@ msgstr ""
msgid "There are no projects shared with this group yet"
msgstr ""
msgid "There are no staged changes"
msgstr ""
msgid "There are no unstaged changes"
msgstr ""
msgid "There are problems accessing Git storage: "
msgstr ""
......@@ -7786,6 +7915,9 @@ msgstr ""
msgid "Unable to sign you in to the group with SAML due to \"%{reason}\""
msgstr ""
msgid "Undo"
msgstr ""
msgid "Unknown"
msgstr ""
......@@ -7801,6 +7933,9 @@ msgstr ""
msgid "Unresolve discussion"
msgstr ""
msgid "Unstage"
msgstr ""
msgid "Unstage all changes"
msgstr ""
......@@ -7870,9 +8005,6 @@ msgstr ""
msgid "Upload file"
msgstr ""
msgid "Upload new avatar"
msgstr ""
msgid "UploadLink|click to upload"
msgstr ""
......@@ -7924,9 +8056,6 @@ msgstr ""
msgid "Users"
msgstr ""
msgid "User|Current status"
msgstr ""
msgid "Variables"
msgstr ""
......@@ -8284,6 +8413,12 @@ msgstr ""
msgid "You need permission."
msgstr ""
msgid "You will loose all changes you've made to this file. This action cannot be undone."
msgstr ""
msgid "You will loose all the unstaged changes you've made in this project. This action cannot be undone."
msgstr ""
msgid "You will not get any notifications via email"
msgstr ""
......
......@@ -21,6 +21,8 @@ Disallow: /groups/new
Disallow: /groups/*/edit
Disallow: /users
Disallow: /help
# Only specifically allow the Sign In page to avoid very ugly search results
Allow: /users/sign_in
# Global snippets
User-Agent: *
......
......@@ -100,7 +100,7 @@ module QA
end
module Sanity
autoload :Failing, 'qa/scenario/test/sanity/failing'
autoload :Framework, 'qa/scenario/test/sanity/framework'
autoload :Selectors, 'qa/scenario/test/sanity/selectors'
end
end
......
......@@ -63,6 +63,14 @@ module QA
'/users/sign_in'
end
def sign_in_tab?
page.has_button?('Sign in')
end
def ldap_tab?
page.has_button?('LDAP')
end
def switch_to_sign_in_tab
click_on 'Sign in'
end
......@@ -90,8 +98,8 @@ module QA
end
def sign_in_using_gitlab_credentials(user)
switch_to_sign_in_tab unless page.has_button?('Sign in')
switch_to_standard_tab if page.has_content?('LDAP')
switch_to_sign_in_tab unless sign_in_tab?
switch_to_standard_tab if ldap_tab?
fill_in :user_login, with: user.username
fill_in :user_password, with: user.password
......
......@@ -5,12 +5,13 @@ module QA
module Test
module Sanity
##
# This scenario exits with a 1 exit code.
# This scenario runs 1 passing example, and 1 failing example, and exits
# with a 1 exit code.
#
class Failing < Template
class Framework < Template
include Bootable
tags :failing
tags :framework
end
end
end
......
# frozen_string_literal: true
module QA
context 'Sanity checks', :orchestrated, :failing do
context 'Framework sanity checks', :orchestrated, :framework do
describe 'Passing orchestrated example' do
it 'succeeds' do
Runtime::Browser.visit(:gitlab, Page::Main::Login)
Page::Main::Login.perform do |main_login|
expect(main_login.sign_in_tab?).to be(true)
end
end
end
describe 'Failing orchestrated example' do
it 'always fails' do
it 'fails' do
Runtime::Browser.visit(:gitlab, Page::Main::Login)
expect(page).to have_text("These Aren't the Texts You're Looking For", wait: 1)
......
describe QA::Scenario::Test::Sanity::Framework do
it_behaves_like 'a QA scenario class' do
let(:tags) { [:framework] }
end
end
......@@ -95,6 +95,7 @@ describe 'bin/changelog' do
it 'shows error message and exits the program' do
allow($stdin).to receive(:getc).and_return(type)
expect do
expect { described_class.read_type }.to raise_error(
ChangelogHelpers::Abort,
......
......@@ -166,8 +166,8 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do
expect(response).to match_response_schema('job/job_details')
expect(json_response['artifact']['download_path']).to match(%r{artifacts/download})
expect(json_response['artifact']['browse_path']).to match(%r{artifacts/browse})
expect(json_response['artifact']).not_to have_key(:expired)
expect(json_response['artifact']).not_to have_key(:expired_at)
expect(json_response['artifact']).not_to have_key('expired')
expect(json_response['artifact']).not_to have_key('expired_at')
end
end
......@@ -177,8 +177,8 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do
it 'exposes needed information' do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('job/job_details')
expect(json_response['artifact']).not_to have_key(:download_path)
expect(json_response['artifact']).not_to have_key(:browse_path)
expect(json_response['artifact']).not_to have_key('download_path')
expect(json_response['artifact']).not_to have_key('browse_path')
expect(json_response['artifact']['expired']).to eq(true)
expect(json_response['artifact']['expire_at']).not_to be_empty
end
......
require 'spec_helper'
describe 'Project > Members > Invite group', :js do
include Select2Helper
include ActionView::Helpers::DateHelper
let(:maintainer) { create(:user) }
describe 'Share with group lock' do
shared_examples 'the project can be shared with groups' do
it 'the "Invite group" tab exists' do
visit project_settings_members_path(project)
expect(page).to have_selector('#invite-group-tab')
end
end
shared_examples 'the project cannot be shared with groups' do
it 'the "Invite group" tab does not exist' do
visit project_settings_members_path(project)
expect(page).not_to have_selector('#invite-group-tab')
end
end
context 'for a project in a root group' do
let!(:group_to_share_with) { create(:group) }
let(:project) { create(:project, namespace: create(:group)) }
before do
project.add_maintainer(maintainer)
sign_in(maintainer)
end
context 'when the group has "Share group lock" disabled' do
it_behaves_like 'the project can be shared with groups'
it 'the project can be shared with another group' do
visit project_settings_members_path(project)
click_on 'invite-group-tab'
select2 group_to_share_with.id, from: '#link_group_id'
page.find('body').click
find('.btn-create').click
page.within('.project-members-groups') do
expect(page).to have_content(group_to_share_with.name)
end
end
end
context 'when the group has "Share group lock" enabled' do
before do
project.namespace.update_column(:share_with_group_lock, true)
end
it_behaves_like 'the project cannot be shared with groups'
end
end
context 'for a project in a subgroup', :nested_groups do
let!(:group_to_share_with) { create(:group) }
let(:root_group) { create(:group) }
let(:subgroup) { create(:group, parent: root_group) }
let(:project) { create(:project, namespace: subgroup) }
before do
project.add_maintainer(maintainer)
sign_in(maintainer)
end
context 'when the root_group has "Share group lock" disabled' do
context 'when the subgroup has "Share group lock" disabled' do
it_behaves_like 'the project can be shared with groups'
end
context 'when the subgroup has "Share group lock" enabled' do
before do
subgroup.update_column(:share_with_group_lock, true)
end
it_behaves_like 'the project cannot be shared with groups'
end
end
context 'when the root_group has "Share group lock" enabled' do
before do
root_group.update_column(:share_with_group_lock, true)
end
context 'when the subgroup has "Share group lock" disabled (parent overridden)' do
it_behaves_like 'the project can be shared with groups'
end
context 'when the subgroup has "Share group lock" enabled' do
before do
subgroup.update_column(:share_with_group_lock, true)
end
it_behaves_like 'the project cannot be shared with groups'
end
end
end
end
describe 'setting an expiration date for a group link' do
let(:project) { create(:project) }
let!(:group) { create(:group) }
around do |example|
Timecop.freeze { example.run }
end
before do
project.add_maintainer(maintainer)
sign_in(maintainer)
visit project_settings_members_path(project)
click_on 'invite-group-tab'
select2 group.id, from: '#link_group_id'
fill_in 'expires_at_groups', with: (Time.now + 4.5.days).strftime('%Y-%m-%d')
click_on 'invite-group-tab'
find('.btn-create').click
end
it 'the group link shows the expiration time with a warning class' do
page.within('.project-members-groups') do
# Using distance_of_time_in_words_to_now because it is not the same as
# subtraction, and this way avoids time zone issues as well
expires_in_text = distance_of_time_in_words_to_now(project.project_group_links.first.expires_at)
expect(page).to have_content(expires_in_text)
expect(page).to have_selector('.text-warning')
end
end
end
describe 'the groups dropdown' do
context 'with multiple groups to choose from' do
let(:project) { create(:project) }
before do
project.add_maintainer(maintainer)
sign_in(maintainer)
create(:group).add_owner(maintainer)
create(:group).add_owner(maintainer)
visit project_settings_members_path(project)
click_link 'Invite group'
find('.ajax-groups-select.select2-container')
execute_script 'GROUP_SELECT_PER_PAGE = 1;'
open_select2 '#link_group_id'
end
it 'should infinitely scroll' do
expect(find('.select2-drop .select2-results')).to have_selector('.select2-result', count: 1)
scroll_select2_to_bottom('.select2-drop .select2-results:visible')
expect(find('.select2-drop .select2-results')).to have_selector('.select2-result', count: 2)
end
end
context 'for a project in a nested group' do
let(:group) { create(:group) }
let!(:nested_group) { create(:group, parent: group) }
let!(:group_to_share_with) { create(:group) }
let!(:project) { create(:project, namespace: nested_group) }
before do
project.add_maintainer(maintainer)
sign_in(maintainer)
group.add_maintainer(maintainer)
group_to_share_with.add_maintainer(maintainer)
end
it 'the groups dropdown does not show ancestors', :nested_groups do
visit project_settings_members_path(project)
click_on 'invite-group-tab'
click_link 'Search for a group'
page.within '.select2-drop' do
expect(page).to have_content(group_to_share_with.name)
expect(page).not_to have_content(group.name)
end
end
end
end
end
......@@ -8,6 +8,7 @@ describe ContributedProjectsFinder do
let!(:public_project) { create(:project, :public) }
let!(:private_project) { create(:project, :private) }
let!(:internal_project) { create(:project, :internal) }
before do
private_project.add_maintainer(source_user)
......@@ -16,17 +17,18 @@ describe ContributedProjectsFinder do
create(:push_event, project: public_project, author: source_user)
create(:push_event, project: private_project, author: source_user)
create(:push_event, project: internal_project, author: source_user)
end
describe 'without a current user' do
describe 'activity without a current user' do
subject { finder.execute }
it { is_expected.to eq([public_project]) }
it { is_expected.to match_array([public_project]) }
end
describe 'with a current user' do
describe 'activity with a current user' do
subject { finder.execute(current_user) }
it { is_expected.to eq([private_project, public_project]) }
it { is_expected.to match_array([private_project, internal_project, public_project]) }
end
end
......@@ -13,22 +13,6 @@ describe UserRecentEventsFinder do
subject(:finder) { described_class.new(current_user, project_owner) }
describe '#execute' do
context 'current user does not have access to projects' do
it 'returns public and internal events' do
records = finder.execute
expect(records).to include(public_event, internal_event)
expect(records).not_to include(private_event)
end
end
context 'when current user has access to the projects' do
before do
private_project.add_developer(current_user)
internal_project.add_developer(current_user)
public_project.add_developer(current_user)
end
context 'when profile is public' do
it 'returns all the events' do
expect(finder.execute).to include(private_event, internal_event, public_event)
......@@ -39,6 +23,7 @@ describe UserRecentEventsFinder do
it 'returns no event' do
allow(Ability).to receive(:allowed?).and_call_original
allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, project_owner).and_return(false)
expect(finder.execute).to be_empty
end
end
......@@ -49,13 +34,4 @@ describe UserRecentEventsFinder do
expect(finder.execute).to be_empty
end
end
context 'when current user is anonymous' do
let(:current_user) { nil }
it 'returns public events only' do
expect(finder.execute).to eq([public_event])
end
end
end
end
......@@ -30,7 +30,7 @@ describe('Multi-file editor commit sidebar list item', () => {
});
it('renders file path', () => {
expect(vm.$el.querySelector('.multi-file-commit-list-path').textContent.trim()).toBe(f.path);
expect(vm.$el.querySelector('.multi-file-commit-list-path').textContent).toContain(f.path);
});
it('renders actionn button', () => {
......
......@@ -29,7 +29,7 @@ describe('IDE stage file button', () => {
});
it('renders button to discard & stage', () => {
expect(vm.$el.querySelectorAll('.btn').length).toBe(2);
expect(vm.$el.querySelectorAll('.btn-blank').length).toBe(2);
});
it('calls store with stage button', () => {
......@@ -39,7 +39,7 @@ describe('IDE stage file button', () => {
});
it('calls store with discard button', () => {
vm.$el.querySelector('.dropdown-menu button').click();
vm.$el.querySelector('.btn-danger').click();
expect(vm.discardFileChanges).toHaveBeenCalledWith(f.path);
});
......
import Vue from 'vue';
import { createStore } from '~/ide/stores';
import Bar from '~/ide/components/file_templates/bar.vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { resetStore, file } from '../../helpers';
describe('IDE file templates bar component', () => {
let Component;
let vm;
beforeAll(() => {
Component = Vue.extend(Bar);
});
beforeEach(() => {
const store = createStore();
store.state.openFiles.push({
...file('file'),
opened: true,
active: true,
});
vm = mountComponentWithStore(Component, { store });
});
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
});
describe('template type dropdown', () => {
it('renders dropdown component', () => {
expect(vm.$el.querySelector('.dropdown').textContent).toContain('Choose a type');
});
it('calls setSelectedTemplateType when clicking item', () => {
spyOn(vm, 'setSelectedTemplateType').and.stub();
vm.$el.querySelector('.dropdown-content button').click();
expect(vm.setSelectedTemplateType).toHaveBeenCalledWith({
name: '.gitlab-ci.yml',
key: 'gitlab_ci_ymls',
});
});
});
describe('template dropdown', () => {
beforeEach(done => {
vm.$store.state.fileTemplates.templates = [
{
name: 'test',
},
];
vm.$store.state.fileTemplates.selectedTemplateType = {
name: '.gitlab-ci.yml',
key: 'gitlab_ci_ymls',
};
vm.$nextTick(done);
});
it('renders dropdown component', () => {
expect(vm.$el.querySelectorAll('.dropdown')[1].textContent).toContain('Choose a template');
});
it('calls fetchTemplate on click', () => {
spyOn(vm, 'fetchTemplate').and.stub();
vm.$el
.querySelectorAll('.dropdown-content')[1]
.querySelector('button')
.click();
expect(vm.fetchTemplate).toHaveBeenCalledWith({
name: 'test',
});
});
});
it('shows undo button if updateSuccess is true', done => {
vm.$store.state.fileTemplates.updateSuccess = true;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.btn-default').style.display).not.toBe('none');
done();
});
});
it('calls undoFileTemplate when clicking undo button', () => {
spyOn(vm, 'undoFileTemplate').and.stub();
vm.$el.querySelector('.btn-default').click();
expect(vm.undoFileTemplate).toHaveBeenCalled();
});
it('calls setSelectedTemplateType if activeFile name matches a template', done => {
const fileName = '.gitlab-ci.yml';
spyOn(vm, 'setSelectedTemplateType');
vm.$store.state.openFiles[0].name = fileName;
vm.setInitialType();
vm.$nextTick(() => {
expect(vm.setSelectedTemplateType).toHaveBeenCalledWith({
name: fileName,
key: 'gitlab_ci_ymls',
});
done();
});
});
});
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment