Commit ae183043 authored by Nick Thomas's avatar Nick Thomas

Merge remote-tracking branch 'upstream/master' into nt/ce-to-ee-thursday

parents 2d97e15e f2cdab59
...@@ -78,6 +78,7 @@ $(() => { ...@@ -78,6 +78,7 @@ $(() => {
} }
gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId); gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId);
Store.rootPath = this.endpoint;
this.filterManager = new FilteredSearchBoards(Store.filter, true, [(this.milestoneTitle ? 'milestone' : null)]); this.filterManager = new FilteredSearchBoards(Store.filter, true, [(this.milestoneTitle ? 'milestone' : null)]);
......
...@@ -81,7 +81,7 @@ const extraMilestones = require('../mixins/extra_milestones'); ...@@ -81,7 +81,7 @@ const extraMilestones = require('../mixins/extra_milestones');
}, },
submit() { submit() {
gl.boardService.createBoard(this.board) gl.boardService.createBoard(this.board)
.then(() => { .then((resp) => {
if (this.currentBoard && this.currentPage !== 'new') { if (this.currentBoard && this.currentPage !== 'new') {
this.currentBoard.name = this.board.name; this.currentBoard.name = this.board.name;
...@@ -89,7 +89,6 @@ const extraMilestones = require('../mixins/extra_milestones'); ...@@ -89,7 +89,6 @@ const extraMilestones = require('../mixins/extra_milestones');
// We reload the page to make sure the store & state of the app are correct // We reload the page to make sure the store & state of the app are correct
this.refreshPage(); this.refreshPage();
} }
}
// Enable the button thanks to our jQuery disabling it // Enable the button thanks to our jQuery disabling it
$(this.$refs.submitBtn).enable(); $(this.$refs.submitBtn).enable();
...@@ -97,6 +96,10 @@ const extraMilestones = require('../mixins/extra_milestones'); ...@@ -97,6 +96,10 @@ const extraMilestones = require('../mixins/extra_milestones');
// Reset the selectors current page // Reset the selectors current page
Store.state.currentPage = ''; Store.state.currentPage = '';
Store.state.reload = true; Store.state.reload = true;
} else if (this.currentPage === 'new') {
const data = resp.json();
gl.utils.visitUrl(`${Store.rootPath}/${data.id}`);
}
}); });
}, },
cancel() { cancel() {
......
/* eslint-disable no-new */ /* eslint-disable no-new, no-undef */
/* global Flash */ /* global Flash */
/** /**
* Renders a deploy board. * Renders a deploy board.
...@@ -67,6 +67,26 @@ export default { ...@@ -67,6 +67,26 @@ export default {
}, },
created() { created() {
this.getDeployBoard();
},
updated() {
// While board is not complete we need to request new data from the server.
// Let's make sure we are not making any request at the moment
// and that we only make this request if the latest response was not 204.
if (!this.isLoading &&
!this.hasError &&
this.deployBoardData.completion &&
this.deployBoardData.completion < 100) {
// let's wait 1s and make the request again
setTimeout(() => {
this.getDeployBoard();
}, 3000);
}
},
methods: {
getDeployBoard() {
this.isLoading = true; this.isLoading = true;
const maxNumberOfRequests = 3; const maxNumberOfRequests = 3;
...@@ -109,6 +129,7 @@ export default { ...@@ -109,6 +129,7 @@ export default {
new Flash('An error occurred while fetching the deploy board.', 'alert'); new Flash('An error occurred while fetching the deploy board.', 'alert');
}); });
}, },
},
computed: { computed: {
canRenderDeployBoard() { canRenderDeployBoard() {
......
import eventHub from '../event_hub';
export default {
name: 'RecentSearchesDropdownContent',
props: {
items: {
type: Array,
required: true,
},
},
computed: {
processedItems() {
return this.items.map((item) => {
const { tokens, searchToken }
= gl.FilteredSearchTokenizer.processTokens(item);
const resultantTokens = tokens.map(token => ({
prefix: `${token.key}:`,
suffix: `${token.symbol}${token.value}`,
}));
return {
text: item,
tokens: resultantTokens,
searchToken,
};
});
},
hasItems() {
return this.items.length > 0;
},
},
methods: {
onItemActivated(text) {
eventHub.$emit('recentSearchesItemSelected', text);
},
onRequestClearRecentSearches(e) {
// Stop the dropdown from closing
e.stopPropagation();
eventHub.$emit('requestClearRecentSearches');
},
},
template: `
<div>
<ul v-if="hasItems">
<li
v-for="(item, index) in processedItems"
:key="index">
<button
type="button"
class="filtered-search-history-dropdown-item"
@click="onItemActivated(item.text)">
<span>
<span
v-for="(token, tokenIndex) in item.tokens"
class="filtered-search-history-dropdown-token">
<span class="name">{{ token.prefix }}</span><span class="value">{{ token.suffix }}</span>
</span>
</span>
<span class="filtered-search-history-dropdown-search-token">
{{ item.searchToken }}
</span>
</button>
</li>
<li class="divider"></li>
<li>
<button
type="button"
class="filtered-search-history-clear-button"
@click="onRequestClearRecentSearches($event)">
Clear recent searches
</button>
</li>
</ul>
<div
v-else
class="dropdown-info-note">
You don't have any recent searches
</div>
</div>
`,
};
...@@ -56,7 +56,7 @@ require('./filtered_search_dropdown'); ...@@ -56,7 +56,7 @@ require('./filtered_search_dropdown');
renderContent() { renderContent() {
const dropdownData = []; const dropdownData = [];
[].forEach.call(this.input.closest('.filtered-search-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => { [].forEach.call(this.input.closest('.filtered-search-box-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => {
const { icon, hint, tag, type } = dropdownMenu.dataset; const { icon, hint, tag, type } = dropdownMenu.dataset;
if (icon && hint && tag) { if (icon && hint && tag) {
dropdownData.push( dropdownData.push(
......
...@@ -129,7 +129,9 @@ import FilteredSearchContainer from './container'; ...@@ -129,7 +129,9 @@ import FilteredSearchContainer from './container';
} }
}); });
return values.join(' '); return values
.map(value => value.trim())
.join(' ');
} }
static getSearchInput(filteredSearchInput) { static getSearchInput(filteredSearchInput) {
......
import Vue from 'vue';
export default new Vue();
/* global Flash */
import FilteredSearchContainer from './container'; import FilteredSearchContainer from './container';
import RecentSearchesRoot from './recent_searches_root';
import RecentSearchesStore from './stores/recent_searches_store';
import RecentSearchesService from './services/recent_searches_service';
import eventHub from './event_hub';
(() => { (() => {
class FilteredSearchManager { class FilteredSearchManager {
constructor(page) { constructor(page) {
this.container = FilteredSearchContainer.container; this.container = FilteredSearchContainer.container;
this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.filteredSearchInputForm = this.filteredSearchInput.form;
this.clearSearchButton = this.container.querySelector('.clear-search'); this.clearSearchButton = this.container.querySelector('.clear-search');
this.tokensContainer = this.container.querySelector('.tokens-container'); this.tokensContainer = this.container.querySelector('.tokens-container');
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
...@@ -13,10 +20,41 @@ import FilteredSearchContainer from './container'; ...@@ -13,10 +20,41 @@ import FilteredSearchContainer from './container';
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeysWithWeights; this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeysWithWeights;
} }
this.recentSearchesStore = new RecentSearchesStore();
let recentSearchesKey = 'issue-recent-searches';
if (page === 'merge_requests') {
recentSearchesKey = 'merge-request-recent-searches';
}
this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
// Fetch recent searches from localStorage
this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch()
.catch(() => {
// eslint-disable-next-line no-new
new Flash('An error occured while parsing recent searches');
// Gracefully fail to empty array
return [];
})
.then((searches) => {
// Put any searches that may have come in before
// we fetched the saved searches ahead of the already saved ones
const resultantSearches = this.recentSearchesStore.setRecentSearches(
this.recentSearchesStore.state.recentSearches.concat(searches),
);
this.recentSearchesService.save(resultantSearches);
});
if (this.filteredSearchInput) { if (this.filteredSearchInput) {
this.tokenizer = gl.FilteredSearchTokenizer; this.tokenizer = gl.FilteredSearchTokenizer;
this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page); this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page);
this.recentSearchesRoot = new RecentSearchesRoot(
this.recentSearchesStore,
this.recentSearchesService,
document.querySelector('.js-filtered-search-history-dropdown'),
);
this.recentSearchesRoot.init();
this.bindEvents(); this.bindEvents();
this.loadSearchParamsFromURL(); this.loadSearchParamsFromURL();
this.dropdownManager.setDropdown(); this.dropdownManager.setDropdown();
...@@ -29,6 +67,10 @@ import FilteredSearchContainer from './container'; ...@@ -29,6 +67,10 @@ import FilteredSearchContainer from './container';
cleanup() { cleanup() {
this.unbindEvents(); this.unbindEvents();
document.removeEventListener('beforeunload', this.cleanupWrapper); document.removeEventListener('beforeunload', this.cleanupWrapper);
if (this.recentSearchesRoot) {
this.recentSearchesRoot.destroy();
}
} }
bindEvents() { bindEvents() {
...@@ -38,7 +80,7 @@ import FilteredSearchContainer from './container'; ...@@ -38,7 +80,7 @@ import FilteredSearchContainer from './container';
this.handleInputPlaceholderWrapper = this.handleInputPlaceholder.bind(this); this.handleInputPlaceholderWrapper = this.handleInputPlaceholder.bind(this);
this.handleInputVisualTokenWrapper = this.handleInputVisualToken.bind(this); this.handleInputVisualTokenWrapper = this.handleInputVisualToken.bind(this);
this.checkForEnterWrapper = this.checkForEnter.bind(this); this.checkForEnterWrapper = this.checkForEnter.bind(this);
this.clearSearchWrapper = this.clearSearch.bind(this); this.onClearSearchWrapper = this.onClearSearch.bind(this);
this.checkForBackspaceWrapper = this.checkForBackspace.bind(this); this.checkForBackspaceWrapper = this.checkForBackspace.bind(this);
this.removeSelectedTokenWrapper = this.removeSelectedToken.bind(this); this.removeSelectedTokenWrapper = this.removeSelectedToken.bind(this);
this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this); this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this);
...@@ -46,8 +88,8 @@ import FilteredSearchContainer from './container'; ...@@ -46,8 +88,8 @@ import FilteredSearchContainer from './container';
this.tokenChange = this.tokenChange.bind(this); this.tokenChange = this.tokenChange.bind(this);
this.addInputContainerFocusWrapper = this.addInputContainerFocus.bind(this); this.addInputContainerFocusWrapper = this.addInputContainerFocus.bind(this);
this.removeInputContainerFocusWrapper = this.removeInputContainerFocus.bind(this); this.removeInputContainerFocusWrapper = this.removeInputContainerFocus.bind(this);
this.onrecentSearchesItemSelectedWrapper = this.onrecentSearchesItemSelected.bind(this);
this.filteredSearchInputForm = this.filteredSearchInput.form;
this.filteredSearchInputForm.addEventListener('submit', this.handleFormSubmit); this.filteredSearchInputForm.addEventListener('submit', this.handleFormSubmit);
this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper); this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper);
this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper); this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper);
...@@ -60,11 +102,12 @@ import FilteredSearchContainer from './container'; ...@@ -60,11 +102,12 @@ import FilteredSearchContainer from './container';
this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper); this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper);
this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken); this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper); this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper);
this.clearSearchButton.addEventListener('click', this.clearSearchWrapper); this.clearSearchButton.addEventListener('click', this.onClearSearchWrapper);
document.addEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens); document.addEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
document.addEventListener('click', this.unselectEditTokensWrapper); document.addEventListener('click', this.unselectEditTokensWrapper);
document.addEventListener('click', this.removeInputContainerFocusWrapper); document.addEventListener('click', this.removeInputContainerFocusWrapper);
document.addEventListener('keydown', this.removeSelectedTokenWrapper); document.addEventListener('keydown', this.removeSelectedTokenWrapper);
eventHub.$on('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
} }
unbindEvents() { unbindEvents() {
...@@ -80,11 +123,12 @@ import FilteredSearchContainer from './container'; ...@@ -80,11 +123,12 @@ import FilteredSearchContainer from './container';
this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper); this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper);
this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken); this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper); this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper);
this.clearSearchButton.removeEventListener('click', this.clearSearchWrapper); this.clearSearchButton.removeEventListener('click', this.onClearSearchWrapper);
document.removeEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens); document.removeEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
document.removeEventListener('click', this.unselectEditTokensWrapper); document.removeEventListener('click', this.unselectEditTokensWrapper);
document.removeEventListener('click', this.removeInputContainerFocusWrapper); document.removeEventListener('click', this.removeInputContainerFocusWrapper);
document.removeEventListener('keydown', this.removeSelectedTokenWrapper); document.removeEventListener('keydown', this.removeSelectedTokenWrapper);
eventHub.$off('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
} }
checkForBackspace(e) { checkForBackspace(e) {
...@@ -137,7 +181,7 @@ import FilteredSearchContainer from './container'; ...@@ -137,7 +181,7 @@ import FilteredSearchContainer from './container';
} }
addInputContainerFocus() { addInputContainerFocus() {
const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container'); const inputContainer = this.filteredSearchInput.closest('.filtered-search-box');
if (inputContainer) { if (inputContainer) {
inputContainer.classList.add('focus'); inputContainer.classList.add('focus');
...@@ -145,7 +189,7 @@ import FilteredSearchContainer from './container'; ...@@ -145,7 +189,7 @@ import FilteredSearchContainer from './container';
} }
removeInputContainerFocus(e) { removeInputContainerFocus(e) {
const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container'); const inputContainer = this.filteredSearchInput.closest('.filtered-search-box');
const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target); const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
const isElementInDynamicFilterDropdown = e.target.closest('.filter-dropdown') !== null; const isElementInDynamicFilterDropdown = e.target.closest('.filter-dropdown') !== null;
const isElementInStaticFilterDropdown = e.target.closest('ul[data-dropdown]') !== null; const isElementInStaticFilterDropdown = e.target.closest('ul[data-dropdown]') !== null;
...@@ -167,7 +211,7 @@ import FilteredSearchContainer from './container'; ...@@ -167,7 +211,7 @@ import FilteredSearchContainer from './container';
} }
unselectEditTokens(e) { unselectEditTokens(e) {
const inputContainer = this.container.querySelector('.filtered-search-input-container'); const inputContainer = this.container.querySelector('.filtered-search-box');
const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target); const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
const isElementInFilterDropdown = e.target.closest('.filter-dropdown') !== null; const isElementInFilterDropdown = e.target.closest('.filter-dropdown') !== null;
const isElementTokensContainer = e.target.classList.contains('tokens-container'); const isElementTokensContainer = e.target.classList.contains('tokens-container');
...@@ -223,9 +267,12 @@ import FilteredSearchContainer from './container'; ...@@ -223,9 +267,12 @@ import FilteredSearchContainer from './container';
} }
} }
clearSearch(e) { onClearSearch(e) {
e.preventDefault(); e.preventDefault();
this.clearSearch();
}
clearSearch() {
this.filteredSearchInput.value = ''; this.filteredSearchInput.value = '';
const removeElements = []; const removeElements = [];
...@@ -299,6 +346,17 @@ import FilteredSearchContainer from './container'; ...@@ -299,6 +346,17 @@ import FilteredSearchContainer from './container';
this.search(); this.search();
} }
saveCurrentSearchQuery() {
// Don't save before we have fetched the already saved searches
this.fetchingRecentSearchesPromise.then(() => {
const searchQuery = gl.DropdownUtils.getSearchQuery();
if (searchQuery.length > 0) {
const resultantSearches = this.recentSearchesStore.addRecentSearch(searchQuery);
this.recentSearchesService.save(resultantSearches);
}
});
}
loadSearchParamsFromURL() { loadSearchParamsFromURL() {
const params = gl.utils.getUrlParamsArray(); const params = gl.utils.getUrlParamsArray();
const usernameParams = this.getUsernameParams(); const usernameParams = this.getUsernameParams();
...@@ -353,6 +411,8 @@ import FilteredSearchContainer from './container'; ...@@ -353,6 +411,8 @@ import FilteredSearchContainer from './container';
} }
}); });
this.saveCurrentSearchQuery();
if (hasFilteredSearch) { if (hasFilteredSearch) {
this.clearSearchButton.classList.remove('hidden'); this.clearSearchButton.classList.remove('hidden');
this.handleInputPlaceholder(); this.handleInputPlaceholder();
...@@ -361,8 +421,12 @@ import FilteredSearchContainer from './container'; ...@@ -361,8 +421,12 @@ import FilteredSearchContainer from './container';
search() { search() {
const paths = []; const paths = [];
const searchQuery = gl.DropdownUtils.getSearchQuery();
this.saveCurrentSearchQuery();
const { tokens, searchToken } const { tokens, searchToken }
= this.tokenizer.processTokens(gl.DropdownUtils.getSearchQuery()); = this.tokenizer.processTokens(searchQuery);
const currentState = gl.utils.getParameterByName('state') || 'opened'; const currentState = gl.utils.getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`); paths.push(`state=${currentState}`);
...@@ -426,6 +490,13 @@ import FilteredSearchContainer from './container'; ...@@ -426,6 +490,13 @@ import FilteredSearchContainer from './container';
currentDropdownRef.dispatchInputEvent(); currentDropdownRef.dispatchInputEvent();
} }
} }
onrecentSearchesItemSelected(text) {
this.clearSearch();
this.filteredSearchInput.value = text;
this.filteredSearchInput.dispatchEvent(new CustomEvent('input'));
this.search();
}
} }
window.gl = window.gl || {}; window.gl = window.gl || {};
......
import Vue from 'vue';
import RecentSearchesDropdownContent from './components/recent_searches_dropdown_content';
import eventHub from './event_hub';
class RecentSearchesRoot {
constructor(
recentSearchesStore,
recentSearchesService,
wrapperElement,
) {
this.store = recentSearchesStore;
this.service = recentSearchesService;
this.wrapperElement = wrapperElement;
}
init() {
this.bindEvents();
this.render();
}
bindEvents() {
this.onRequestClearRecentSearchesWrapper = this.onRequestClearRecentSearches.bind(this);
eventHub.$on('requestClearRecentSearches', this.onRequestClearRecentSearchesWrapper);
}
unbindEvents() {
eventHub.$off('requestClearRecentSearches', this.onRequestClearRecentSearchesWrapper);
}
render() {
this.vm = new Vue({
el: this.wrapperElement,
data: this.store.state,
template: `
<recent-searches-dropdown-content
:items="recentSearches" />
`,
components: {
'recent-searches-dropdown-content': RecentSearchesDropdownContent,
},
});
}
onRequestClearRecentSearches() {
const resultantSearches = this.store.setRecentSearches([]);
this.service.save(resultantSearches);
}
destroy() {
this.unbindEvents();
if (this.vm) {
this.vm.$destroy();
}
}
}
export default RecentSearchesRoot;
class RecentSearchesService {
constructor(localStorageKey = 'issuable-recent-searches') {
this.localStorageKey = localStorageKey;
}
fetch() {
const input = window.localStorage.getItem(this.localStorageKey);
let searches = [];
if (input && input.length > 0) {
try {
searches = JSON.parse(input);
} catch (err) {
return Promise.reject(err);
}
}
return Promise.resolve(searches);
}
save(searches = []) {
window.localStorage.setItem(this.localStorageKey, JSON.stringify(searches));
}
}
export default RecentSearchesService;
import _ from 'underscore';
class RecentSearchesStore {
constructor(initialState = {}) {
this.state = Object.assign({
recentSearches: [],
}, initialState);
}
addRecentSearch(newSearch) {
this.setRecentSearches([newSearch].concat(this.state.recentSearches));
return this.state.recentSearches;
}
setRecentSearches(searches = []) {
const trimmedSearches = searches.map(search => search.trim());
this.state.recentSearches = _.uniq(trimmedSearches).slice(0, 5);
return this.state.recentSearches;
}
}
export default RecentSearchesStore;
...@@ -177,10 +177,6 @@ ...@@ -177,10 +177,6 @@
border-radius: $border-radius-base; border-radius: $border-radius-base;
box-shadow: 0 2px 4px $dropdown-shadow-color; box-shadow: 0 2px 4px $dropdown-shadow-color;
.filtered-search-input-container & {
max-width: 280px;
}
&.is-loading { &.is-loading {
.dropdown-content { .dropdown-content {
display: none; display: none;
...@@ -467,6 +463,11 @@ ...@@ -467,6 +463,11 @@
overflow-y: auto; overflow-y: auto;
} }
.dropdown-info-note {
color: $gl-text-color-secondary;
text-align: center;
}
.dropdown-footer { .dropdown-footer {
padding-top: 10px; padding-top: 10px;
margin-top: 10px; margin-top: 10px;
......
...@@ -22,7 +22,6 @@ ...@@ -22,7 +22,6 @@
} }
@media (min-width: $screen-sm-min) { @media (min-width: $screen-sm-min) {
.issues-filters,
.issues_bulk_update { .issues_bulk_update {
.dropdown-menu-toggle:not(.wide) { .dropdown-menu-toggle:not(.wide) {
width: 132px; width: 132px;
...@@ -56,7 +55,7 @@ ...@@ -56,7 +55,7 @@
} }
} }
.filtered-search-container { .filtered-search-wrapper {
display: -webkit-flex; display: -webkit-flex;
display: flex; display: flex;
...@@ -151,11 +150,13 @@ ...@@ -151,11 +150,13 @@
width: 100%; width: 100%;
} }
.filtered-search-input-container { .filtered-search-box {
position: relative;
flex: 1;
display: -webkit-flex; display: -webkit-flex;
display: flex; display: flex;
position: relative;
width: 100%; width: 100%;
min-width: 0;
border: 1px solid $border-color; border: 1px solid $border-color;
background-color: $white-light; background-color: $white-light;
...@@ -163,14 +164,6 @@ ...@@ -163,14 +164,6 @@
-webkit-flex: 1 1 auto; -webkit-flex: 1 1 auto;
flex: 1 1 auto; flex: 1 1 auto;
margin-bottom: 10px; margin-bottom: 10px;
.dropdown-menu {
width: auto;
left: 0;
right: 0;
max-width: none;
min-width: 100%;
}
} }
&:hover { &:hover {
...@@ -229,6 +222,118 @@ ...@@ -229,6 +222,118 @@
} }
} }
.filtered-search-box-input-container {
flex: 1;
position: relative;
// Fix PhantomJS not supporting `flex: 1;` properly.
// This is important because it can change the expected `e.target` when clicking things in tests.
// See https://gitlab.com/gitlab-org/gitlab-ce/blob/b54acba8b732688c59fe2f38510c469dc86ee499/spec/features/issues/filtered_search/visual_tokens_spec.rb#L61
// - With `width: 100%`: `e.target` = `.tokens-container`, https://i.imgur.com/jGq7wbx.png
// - Without `width: 100%`: `e.target` = `.filtered-search`, https://i.imgur.com/cNI2CyT.png
width: 100%;
min-width: 0;
}
.filtered-search-input-dropdown-menu {
max-width: 280px;
@media (max-width: $screen-xs-min) {
width: auto;
left: 0;
right: 0;
max-width: none;
min-width: 100%;
}
}
.filtered-search-history-dropdown-toggle-button {
display: flex;
align-items: center;
width: auto;
height: 100%;
padding-top: 0;
padding-left: 0.75em;
padding-bottom: 0;
padding-right: 0.5em;
background-color: transparent;
border-radius: 0;
border-top: 0;
border-left: 0;
border-bottom: 0;
border-right: 1px solid $border-color;
color: $gl-text-color-secondary;
transition: color 0.1s linear;
&:hover,
&:focus {
color: $gl-text-color;
border-color: $dropdown-input-focus-border;
outline: none;
}
.dropdown-toggle-text {
color: inherit;
.fa {
color: inherit;
}
}
.fa {
position: initial;
}
}
.filtered-search-history-dropdown-wrapper {
position: initial;
flex-shrink: 0;
}
.filtered-search-history-dropdown {
width: 40%;
@media (max-width: $screen-xs-min) {
left: 0;
right: 0;
max-width: none;
}
}
.filtered-search-history-dropdown-content {
max-height: none;
}
.filtered-search-history-dropdown-item,
.filtered-search-history-clear-button {
@include dropdown-link;
overflow: hidden;
width: 100%;
margin: 0.5em 0;
background-color: transparent;
border: 0;
text-align: left;
white-space: nowrap;
text-overflow: ellipsis;
}
.filtered-search-history-dropdown-token {
display: inline;
&:not(:last-child) {
margin-right: 0.3em;
}
& > .value {
font-weight: 600;
}
}
.filter-dropdown-container { .filter-dropdown-container {
display: -webkit-flex; display: -webkit-flex;
display: flex; display: flex;
...@@ -248,11 +353,9 @@ ...@@ -248,11 +353,9 @@
} }
@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
.issues-details-filters { .issue-bulk-update-dropdown-toggle {
.dropdown-menu-toggle {
width: 100px; width: 100px;
} }
}
} }
@media (max-width: $screen-xs-max) { @media (max-width: $screen-xs-max) {
......
...@@ -166,7 +166,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController ...@@ -166,7 +166,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
:shared_runners_minutes, :shared_runners_minutes,
:usage_ping_enabled, :usage_ping_enabled,
:minimum_mirror_sync_time, :minimum_mirror_sync_time,
:geo_status_timeout :geo_status_timeout,
:elasticsearch_experimental_indexer,
] ]
end end
end end
...@@ -132,6 +132,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController ...@@ -132,6 +132,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController
rollout_status = @environment.rollout_status rollout_status = @environment.rollout_status
Gitlab::PollingInterval.set_header(response, interval: 3000) unless rollout_status.try(:complete?)
if rollout_status.nil? if rollout_status.nil?
render body: nil, status: 204 # no result yet render body: nil, status: 204 # no result yet
else else
......
module DropdownsHelper module DropdownsHelper
def dropdown_tag(toggle_text, options: {}, &block) def dropdown_tag(toggle_text, options: {}, &block)
content_tag :div, class: "dropdown" do content_tag :div, class: "dropdown #{options[:wrapper_class] if options.has_key?(:wrapper_class)}" do
data_attr = { toggle: "dropdown" } data_attr = { toggle: "dropdown" }
if options.has_key?(:data) if options.has_key?(:data)
...@@ -24,7 +24,7 @@ module DropdownsHelper ...@@ -24,7 +24,7 @@ module DropdownsHelper
output << dropdown_filter(options[:placeholder]) output << dropdown_filter(options[:placeholder])
end end
output << content_tag(:div, class: "dropdown-content") do output << content_tag(:div, class: "dropdown-content #{options[:content_class] if options.has_key?(:content_class)}") do
capture(&block) if block && !options.has_key?(:footer_content) capture(&block) if block && !options.has_key?(:footer_content)
end end
......
...@@ -3,13 +3,14 @@ class AwardEmoji < ActiveRecord::Base ...@@ -3,13 +3,14 @@ class AwardEmoji < ActiveRecord::Base
UPVOTE_NAME = "thumbsup".freeze UPVOTE_NAME = "thumbsup".freeze
include Participable include Participable
include GhostUser
belongs_to :awardable, polymorphic: true belongs_to :awardable, polymorphic: true
belongs_to :user belongs_to :user
validates :awardable, :user, presence: true validates :awardable, :user, presence: true
validates :name, presence: true, inclusion: { in: Gitlab::Emoji.emojis_names } validates :name, presence: true, inclusion: { in: Gitlab::Emoji.emojis_names }
validates :name, uniqueness: { scope: [:user, :awardable_type, :awardable_id] } validates :name, uniqueness: { scope: [:user, :awardable_type, :awardable_id] }, unless: :ghost_user?
participant :user participant :user
......
module GhostUser
extend ActiveSupport::Concern
def ghost_user?
user && user.ghost?
end
end
...@@ -95,7 +95,8 @@ class User < ActiveRecord::Base ...@@ -95,7 +95,8 @@ class User < ActiveRecord::Base
has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy
has_many :approvals, dependent: :destroy has_many :approvals, dependent: :destroy
has_many :approvers, dependent: :destroy has_many :approvers, dependent: :destroy
has_one :abuse_report, dependent: :destroy has_one :abuse_report, dependent: :destroy, foreign_key: :user_id
has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport"
has_many :spam_logs, dependent: :destroy has_many :spam_logs, dependent: :destroy
has_many :builds, dependent: :nullify, class_name: 'Ci::Build' has_many :builds, dependent: :nullify, class_name: 'Ci::Build'
has_many :pipelines, dependent: :nullify, class_name: 'Ci::Pipeline' has_many :pipelines, dependent: :nullify, class_name: 'Ci::Pipeline'
......
...@@ -26,7 +26,7 @@ module Users ...@@ -26,7 +26,7 @@ module Users
::Projects::DestroyService.new(project, current_user, skip_repo: true).execute ::Projects::DestroyService.new(project, current_user, skip_repo: true).execute
end end
move_issues_to_ghost_user(user) MigrateToGhostUserService.new(user).execute
# Destroy the namespace after destroying the user since certain methods may depend on the namespace existing # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing
namespace = user.namespace namespace = user.namespace
...@@ -35,22 +35,5 @@ module Users ...@@ -35,22 +35,5 @@ module Users
user_data user_data
end end
private
def move_issues_to_ghost_user(user)
# Block the user before moving issues to prevent a data race.
# If the user creates an issue after `move_issues_to_ghost_user`
# runs and before the user is destroyed, the destroy will fail with
# an exception. We block the user so that issues can't be created
# after `move_issues_to_ghost_user` runs and before the destroy happens.
user.block
ghost_user = User.ghost
user.issues.update_all(author_id: ghost_user.id)
user.reload
end
end end
end end
# When a user is destroyed, some of their associated records are
# moved to a "Ghost User", to prevent these associated records from
# being destroyed.
#
# For example, all the issues/MRs a user has created are _not_ destroyed
# when the user is destroyed.
module Users
class MigrateToGhostUserService
extend ActiveSupport::Concern
attr_reader :ghost_user, :user
def initialize(user)
@user = user
end
def execute
# Block the user before moving records to prevent a data race.
# For example, if the user creates an issue after `migrate_issues`
# runs and before the user is destroyed, the destroy will fail with
# an exception.
user.block
user.transaction do
@ghost_user = User.ghost
migrate_issues
migrate_merge_requests
migrate_notes
migrate_abuse_reports
migrate_award_emoji
end
user.reload
end
private
def migrate_issues
user.issues.update_all(author_id: ghost_user.id)
end
def migrate_merge_requests
user.merge_requests.update_all(author_id: ghost_user.id)
end
def migrate_notes
user.notes.update_all(author_id: ghost_user.id)
end
def migrate_abuse_reports
user.reported_abuse_reports.update_all(reporter_id: ghost_user.id)
end
def migrate_award_emoji
user.award_emoji.update_all(user_id: ghost_user.id)
end
end
end
...@@ -469,6 +469,13 @@ ...@@ -469,6 +469,13 @@
= f.check_box :elasticsearch_indexing = f.check_box :elasticsearch_indexing
Elasticsearch indexing Elasticsearch indexing
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
= f.label :elasticsearch_experimental_indexer do
= f.check_box :elasticsearch_experimental_indexer
Use experimental repository indexer
.form-group .form-group
.col-sm-offset-2.col-sm-10 .col-sm-offset-2.col-sm-10
.checkbox .checkbox
......
...@@ -41,21 +41,21 @@ ...@@ -41,21 +41,21 @@
.issues_bulk_update.hide .issues_bulk_update.hide
= form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: 'bulk-update' do = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: 'bulk-update' do
.filter-item.inline .filter-item.inline
= dropdown_tag("Status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do = dropdown_tag("Status", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do
%ul %ul
%li %li
%a{ href: "#", data: { id: "reopen" } } Open %a{ href: "#", data: { id: "reopen" } } Open
%li %li
%a{ href: "#", data: {id: "close" } } Closed %a{ href: "#", data: {id: "close" } } Closed
.filter-item.inline .filter-item.inline
= dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", = dropdown_tag("Assignee", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable",
placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]", default_label: "Assignee" } }) placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]", default_label: "Assignee" } })
.filter-item.inline .filter-item.inline
= dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", default_label: "Milestone", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } }) = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'issue-bulk-update-dropdown-toggle js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", default_label: "Milestone", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } })
.filter-item.inline.labels-filter .filter-item.inline.labels-filter
= render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], dropdown_title: 'Apply a label', show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true } = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], dropdown_title: 'Apply a label', show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true }
.filter-item.inline .filter-item.inline
= dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do = dropdown_tag("Subscription", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do
%ul %ul
%li %li
%a{ href: "#", data: { id: "subscribe" } } Subscribe %a{ href: "#", data: { id: "subscribe" } } Subscribe
......
...@@ -14,8 +14,16 @@ ...@@ -14,8 +14,16 @@
.check-all-holder .check-all-holder
= check_box_tag "check_all_issues", nil, false, = check_box_tag "check_all_issues", nil, false,
class: "check_all_issues left" class: "check_all_issues left"
.issues-other-filters.filtered-search-container .issues-other-filters.filtered-search-wrapper
.filtered-search-input-container .filtered-search-box
= dropdown_tag(content_tag(:i, '', class: 'fa fa-history'),
options: { wrapper_class: "filtered-search-history-dropdown-wrapper",
toggle_class: "filtered-search-history-dropdown-toggle-button",
dropdown_class: "filtered-search-history-dropdown",
content_class: "filtered-search-history-dropdown-content",
title: "Recent searches" }) do
.js-filtered-search-history-dropdown
.filtered-search-box-input-container
.scroll-container .scroll-container
%ul.tokens-container.list-unstyled %ul.tokens-container.list-unstyled
%li.input-token %li.input-token
...@@ -23,7 +31,7 @@ ...@@ -23,7 +31,7 @@
= icon('filter') = icon('filter')
%button.clear-search.hidden{ type: 'button' } %button.clear-search.hidden{ type: 'button' }
= icon('times') = icon('times')
#js-dropdown-hint.dropdown-menu.hint-dropdown #js-dropdown-hint.filtered-search-input-dropdown-menu.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul{ data: { dropdown: true } } %ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { action: 'submit' } } %li.filter-dropdown-item{ data: { action: 'submit' } }
%button.btn.btn-link %button.btn.btn-link
...@@ -40,7 +48,7 @@ ...@@ -40,7 +48,7 @@
{{hint}} {{hint}}
%span.js-filter-tag.dropdown-light-content %span.js-filter-tag.dropdown-light-content
{{tag}} {{tag}}
#js-dropdown-author.dropdown-menu{ data: { icon: 'pencil', hint: 'author', tag: '@author' } } #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'pencil', hint: 'author', tag: '@author' } }
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item %li.filter-dropdown-item
%button.btn.btn-link.dropdown-user %button.btn.btn-link.dropdown-user
...@@ -50,7 +58,7 @@ ...@@ -50,7 +58,7 @@
{{name}} {{name}}
%span.dropdown-light-content %span.dropdown-light-content
@{{username}} @{{username}}
#js-dropdown-assignee.dropdown-menu{ data: { icon: 'user', hint: 'assignee', tag: '@assignee' } } #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'user', hint: 'assignee', tag: '@assignee' } }
%ul{ data: { dropdown: true } } %ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } } %li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link %button.btn.btn-link
...@@ -66,7 +74,7 @@ ...@@ -66,7 +74,7 @@
%span.dropdown-light-content %span.dropdown-light-content
@{{username}} @{{username}}
- unless board && board.milestone_id - unless board && board.milestone_id
#js-dropdown-milestone.dropdown-menu{ data: { icon: 'clock-o', hint: 'milestone', tag: '%milestone' } } #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'clock-o', hint: 'milestone', tag: '%milestone' } }
%ul{ data: { dropdown: true } } %ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } } %li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link %button.btn.btn-link
...@@ -82,7 +90,7 @@ ...@@ -82,7 +90,7 @@
%li.filter-dropdown-item %li.filter-dropdown-item
%button.btn.btn-link.js-data-value %button.btn.btn-link.js-data-value
{{title}} {{title}}
#js-dropdown-label.dropdown-menu{ data: { icon: 'tag', hint: 'label', tag: '~label', type: 'array' } } #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'tag', hint: 'label', tag: '~label', type: 'array' } }
%ul{ data: { dropdown: true } } %ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } } %li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link %button.btn.btn-link
...@@ -96,7 +104,7 @@ ...@@ -96,7 +104,7 @@
{{title}} {{title}}
- if type == :issues || type == :boards - if type == :issues || type == :boards
#js-dropdown-weight.dropdown-menu{ data: { icon: 'balance-scale', hint: 'weight', tag: 'weight' } } #js-dropdown-weight.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'balance-scale', hint: 'weight', tag: 'weight' } }
%ul{ 'data-dropdown' => true } %ul{ 'data-dropdown' => true }
%li.filter-dropdown-item{ 'data-value' => 'none' } %li.filter-dropdown-item{ 'data-value' => 'none' }
%button.btn.btn-link %button.btn.btn-link
......
class GeoRepositoryFetchWorker class GeoRepositoryFetchWorker
include Sidekiq::Worker include Sidekiq::Worker
include ::GeoDynamicBackoff
include GeoQueue
include Gitlab::ShellAdapter include Gitlab::ShellAdapter
sidekiq_options queue: 'geo_repository_update' sidekiq_options queue: 'geo_repository_update'
def perform(project_id, clone_url) def perform(project_id, clone_url)
...@@ -12,5 +15,7 @@ class GeoRepositoryFetchWorker ...@@ -12,5 +15,7 @@ class GeoRepositoryFetchWorker
project.repository.expire_all_method_caches project.repository.expire_all_method_caches
project.repository.expire_branch_cache project.repository.expire_branch_cache
project.repository.expire_content_cache project.repository.expire_content_cache
rescue Gitlab::Shell::Error => e
logger.error "Error fetching repository for project #{project.path_with_namespace}: #{e}"
end end
end end
---
title: 'elasticsearch: Add support for an experimental repository indexer'
merge_request: 1483
author:
---
title: 'Geo: handle git failures on GeoRepositoryFetchWorker'
merge_request:
author:
---
title: Recent search history for issues
merge_request:
author:
---
title: Deleting a user should not delete associated records
merge_request: 10467
author:
class AddElasticsearchExperimentalIndexerToApplicationSettings < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_column :application_settings, :elasticsearch_experimental_indexer, :boolean
end
def down
remove_column :application_settings, :elasticsearch_experimental_indexer
end
end
...@@ -131,6 +131,7 @@ ActiveRecord::Schema.define(version: 20170405080720) do ...@@ -131,6 +131,7 @@ ActiveRecord::Schema.define(version: 20170405080720) do
t.integer "geo_status_timeout", default: 10 t.integer "geo_status_timeout", default: 10
t.string "uuid" t.string "uuid"
t.decimal "polling_interval_multiplier", default: 1.0, null: false t.decimal "polling_interval_multiplier", default: 1.0, null: false
t.boolean "elasticsearch_experimental_indexer"
end end
create_table "approvals", force: :cascade do |t| create_table "approvals", force: :cascade do |t|
......
...@@ -55,6 +55,7 @@ The following Elasticsearch settings are available: ...@@ -55,6 +55,7 @@ The following Elasticsearch settings are available:
| Parameter | Description | | Parameter | Description |
| --------- | ----------- | | --------- | ----------- |
| `Elasticsearch indexing` | Enables/disables Elasticsearch indexing. You may want to enable indexing but disable search in order to give the index time to be fully completed, for example. Also keep in mind that this option doesn't have any impact on existing data, this only enables/disables background indexer which tracks data changes. So by enabling this you will not get your existing data indexed, use special rake task for that as explained in [Add GitLab's data to the Elasticsearch index](#add-gitlabs-data-to-the-elasticsearch-index). | | `Elasticsearch indexing` | Enables/disables Elasticsearch indexing. You may want to enable indexing but disable search in order to give the index time to be fully completed, for example. Also keep in mind that this option doesn't have any impact on existing data, this only enables/disables background indexer which tracks data changes. So by enabling this you will not get your existing data indexed, use special rake task for that as explained in [Add GitLab's data to the Elasticsearch index](#add-gitlabs-data-to-the-elasticsearch-index). |
| `Use experimental repository indexer` | Perform repository indexing using [GitLab Elasticsearch Indexer](https://gitlab.com/gitlab-org/gitlab-elasticsearch-indexer). |
| `Search with Elasticsearch enabled` | Enables/disables using Elasticsearch in search. | | `Search with Elasticsearch enabled` | Enables/disables using Elasticsearch in search. |
| `URL` | The URL to use for connecting to Elasticsearch. Use a comma-separated list to support clustering (e.g., "http://host1, https://host2:9200"). | | `URL` | The URL to use for connecting to Elasticsearch. Use a comma-separated list to support clustering (e.g., "http://host1, https://host2:9200"). |
| `Using AWS hosted Elasticsearch with IAM credentials` | Sign your Elasticsearch requests using [AWS IAM authorization][aws-iam]. The access key must be allowed to perform `es:*` actions. | | `Using AWS hosted Elasticsearch with IAM credentials` | Sign your Elasticsearch requests using [AWS IAM authorization][aws-iam]. The access key must be allowed to perform `es:*` actions. |
......
...@@ -2,3 +2,4 @@ ...@@ -2,3 +2,4 @@
- [Preferences](../user/profile/preferences.md) - [Preferences](../user/profile/preferences.md)
- [Two-factor Authentication (2FA)](../user/profile/account/two_factor_authentication.md) - [Two-factor Authentication (2FA)](../user/profile/account/two_factor_authentication.md)
- [Deleting your account](../user/profile/account/delete_account.md)
...@@ -62,6 +62,7 @@ The total number of the following is sent back to GitLab Inc.: ...@@ -62,6 +62,7 @@ The total number of the following is sent back to GitLab Inc.:
- Deploy keys - Deploy keys
- Pages - Pages
- Project Services - Project Services
- Projects using the Prometheus service
- Issue Boards - Issue Boards
- CI Runners - CI Runners
- Deployments - Deployments
......
# Deleting a User Account
- As a user, you can delete your own account by navigating to **Settings** > **Account** and selecting **Delete account**
- As an admin, you can delete a user account by navigating to the **Admin Area**, selecting the **Users** tab, selecting a user, and clicking on **Remvoe user**
## Associated Records
> Introduced for issues in [GitLab 9.0][ce-7393], and for merge requests, award emoji, notes, and abuse reports in [GitLab 9.1][ce-10467].
When a user account is deleted, not all associated records are deleted with it. Here's a list of things that will not be deleted:
- Issues that the user created
- Merge requests that the user created
- Notes that the user created
- Abuse reports that the user reported
- Award emoji that the user craeted
Instead of being deleted, these records will be moved to a system-wide "Ghost User", whose sole purpose is to act as a container for such records.
[ce-7393]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7393
[ce-10467]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10467
...@@ -6,6 +6,8 @@ module Gitlab ...@@ -6,6 +6,8 @@ module Gitlab
class Indexer class Indexer
include Gitlab::CurrentSettings include Gitlab::CurrentSettings
EXPERIMENTAL_INDEXER = 'gitlab-elasticsearch-indexer'.freeze
Error = Class.new(StandardError) Error = Class.new(StandardError)
attr_reader :project attr_reader :project
...@@ -44,7 +46,11 @@ module Gitlab ...@@ -44,7 +46,11 @@ module Gitlab
end end
def path_to_indexer def path_to_indexer
File.join(Rails.root, 'bin/elastic_repo_indexer') if current_application_settings.elasticsearch_experimental_indexer?
EXPERIMENTAL_INDEXER
else
Rails.root.join('bin', 'elastic_repo_indexer').to_s
end
end end
def run_indexer!(from_sha, to_sha) def run_indexer!(from_sha, to_sha)
......
...@@ -40,6 +40,7 @@ module Gitlab ...@@ -40,6 +40,7 @@ module Gitlab
notes: Note.count, notes: Note.count,
pages_domains: PagesDomain.count, pages_domains: PagesDomain.count,
projects: Project.count, projects: Project.count,
projects_prometheus_active: PrometheusService.active.count,
protected_branches: ProtectedBranch.count, protected_branches: ProtectedBranch.count,
releases: Release.count, releases: Release.count,
remote_mirrors: RemoteMirror.count, remote_mirrors: RemoteMirror.count,
......
...@@ -266,6 +266,7 @@ describe Projects::EnvironmentsController do ...@@ -266,6 +266,7 @@ describe Projects::EnvironmentsController do
get :status, environment_params get :status, environment_params
expect(response.status).to eq(204) expect(response.status).to eq(204)
expect(response.headers['Poll-Interval']).to eq(3000)
end end
it 'returns the rollout status when present' do it 'returns the rollout status when present' do
......
...@@ -24,9 +24,8 @@ describe 'Board with milestone', :feature, :js do ...@@ -24,9 +24,8 @@ describe 'Board with milestone', :feature, :js do
it 'creates board with milestone' do it 'creates board with milestone' do
create_board_with_milestone create_board_with_milestone
click_link 'test'
expect(find('.tokens-container')).to have_content(milestone.title) expect(find('.tokens-container')).to have_content(milestone.title)
wait_for_vue_resource
expect(all('.board')[1]).to have_selector('.card', count: 1) expect(all('.board')[1]).to have_selector('.card', count: 1)
end end
end end
......
...@@ -590,7 +590,7 @@ describe 'Issue Boards', feature: true, js: true do ...@@ -590,7 +590,7 @@ describe 'Issue Boards', feature: true, js: true do
end end
def click_filter_link(link_text) def click_filter_link(link_text)
page.within('.filtered-search-input-container') do page.within('.filtered-search-box') do
expect(page).to have_button(link_text) expect(page).to have_button(link_text)
click_button(link_text) click_button(link_text)
......
...@@ -219,7 +219,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do ...@@ -219,7 +219,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
end end
def click_filter_link(link_text) def click_filter_link(link_text)
page.within('.add-issues-modal .filtered-search-input-container') do page.within('.add-issues-modal .filtered-search-box') do
expect(page).to have_button(link_text) expect(page).to have_button(link_text)
click_button(link_text) click_button(link_text)
......
...@@ -194,7 +194,7 @@ describe 'Dropdown assignee', :feature, :js do ...@@ -194,7 +194,7 @@ describe 'Dropdown assignee', :feature, :js do
new_user = create(:user) new_user = create(:user)
project.team << [new_user, :master] project.team << [new_user, :master]
find('.filtered-search-input-container .clear-search').click find('.filtered-search-box .clear-search').click
filtered_search.set('assignee') filtered_search.set('assignee')
filtered_search.send_keys(':') filtered_search.send_keys(':')
......
...@@ -172,7 +172,7 @@ describe 'Dropdown author', js: true, feature: true do ...@@ -172,7 +172,7 @@ describe 'Dropdown author', js: true, feature: true do
new_user = create(:user) new_user = create(:user)
project.team << [new_user, :master] project.team << [new_user, :master]
find('.filtered-search-input-container .clear-search').click find('.filtered-search-box .clear-search').click
filtered_search.set('author') filtered_search.set('author')
send_keys_to_filtered_search(':') send_keys_to_filtered_search(':')
......
...@@ -33,7 +33,7 @@ describe 'Dropdown label', js: true, feature: true do ...@@ -33,7 +33,7 @@ describe 'Dropdown label', js: true, feature: true do
end end
def clear_search_field def clear_search_field
find('.filtered-search-input-container .clear-search').click find('.filtered-search-box .clear-search').click
end end
before do before do
......
...@@ -252,7 +252,7 @@ describe 'Dropdown milestone', :feature, :js do ...@@ -252,7 +252,7 @@ describe 'Dropdown milestone', :feature, :js do
expect(initial_size).to be > 0 expect(initial_size).to be > 0
create(:milestone, project: project) create(:milestone, project: project)
find('.filtered-search-input-container .clear-search').click find('.filtered-search-box .clear-search').click
filtered_search.set('milestone:') filtered_search.set('milestone:')
expect(dropdown_milestone_size).to eq(initial_size) expect(dropdown_milestone_size).to eq(initial_size)
......
...@@ -758,10 +758,10 @@ describe 'Filter issues', js: true, feature: true do ...@@ -758,10 +758,10 @@ describe 'Filter issues', js: true, feature: true do
expect_issues_list_count(2) expect_issues_list_count(2)
sort_toggle = find('.filtered-search-container .dropdown-toggle') sort_toggle = find('.filtered-search-wrapper .dropdown-toggle')
sort_toggle.click sort_toggle.click
find('.filtered-search-container .dropdown-menu li a', text: 'Oldest updated').click find('.filtered-search-wrapper .dropdown-menu li a', text: 'Oldest updated').click
wait_for_ajax wait_for_ajax
expect(find('.issues-list .issue:first-of-type .issue-title-text a')).to have_content(old_issue.title) expect(find('.issues-list .issue:first-of-type .issue-title-text a')).to have_content(old_issue.title)
......
require 'spec_helper'
describe 'Recent searches', js: true, feature: true do
include FilteredSearchHelpers
include WaitForAjax
let!(:group) { create(:group) }
let!(:project) { create(:project, group: group) }
let!(:user) { create(:user) }
before do
Capybara.ignore_hidden_elements = false
project.add_master(user)
group.add_developer(user)
create(:issue, project: project)
login_as(user)
remove_recent_searches
end
after do
Capybara.ignore_hidden_elements = true
end
it 'searching adds to recent searches' do
visit namespace_project_issues_path(project.namespace, project)
input_filtered_search('foo', submit: true)
input_filtered_search('bar', submit: true)
items = all('.filtered-search-history-dropdown-item', visible: false)
expect(items.count).to eq(2)
expect(items[0].text).to eq('bar')
expect(items[1].text).to eq('foo')
end
it 'visiting URL with search params adds to recent searches' do
visit namespace_project_issues_path(project.namespace, project, label_name: 'foo', search: 'bar')
visit namespace_project_issues_path(project.namespace, project, label_name: 'qux', search: 'garply')
items = all('.filtered-search-history-dropdown-item', visible: false)
expect(items.count).to eq(2)
expect(items[0].text).to eq('label:~qux garply')
expect(items[1].text).to eq('label:~foo bar')
end
it 'saved recent searches are restored last on the list' do
set_recent_searches('["saved1", "saved2"]')
visit namespace_project_issues_path(project.namespace, project, search: 'foo')
items = all('.filtered-search-history-dropdown-item', visible: false)
expect(items.count).to eq(3)
expect(items[0].text).to eq('foo')
expect(items[1].text).to eq('saved1')
expect(items[2].text).to eq('saved2')
end
it 'clicking item fills search input' do
set_recent_searches('["foo", "bar"]')
visit namespace_project_issues_path(project.namespace, project)
all('.filtered-search-history-dropdown-item', visible: false)[0].trigger('click')
wait_for_filtered_search('foo')
expect(find('.filtered-search').value.strip).to eq('foo')
end
it 'clear recent searches button, clears recent searches' do
set_recent_searches('["foo"]')
visit namespace_project_issues_path(project.namespace, project)
items_before = all('.filtered-search-history-dropdown-item', visible: false)
expect(items_before.count).to eq(1)
find('.filtered-search-history-clear-button', visible: false).trigger('click')
items_after = all('.filtered-search-history-dropdown-item', visible: false)
expect(items_after.count).to eq(0)
end
it 'shows flash error when failed to parse saved history' do
set_recent_searches('fail')
visit namespace_project_issues_path(project.namespace, project)
expect(find('.flash-alert')).to have_text('An error occured while parsing recent searches')
end
end
...@@ -44,7 +44,7 @@ describe 'Search bar', js: true, feature: true do ...@@ -44,7 +44,7 @@ describe 'Search bar', js: true, feature: true do
filtered_search.set(search_text) filtered_search.set(search_text)
expect(filtered_search.value).to eq(search_text) expect(filtered_search.value).to eq(search_text)
find('.filtered-search-input-container .clear-search').click find('.filtered-search-box .clear-search').click
expect(filtered_search.value).to eq('') expect(filtered_search.value).to eq('')
end end
...@@ -55,7 +55,7 @@ describe 'Search bar', js: true, feature: true do ...@@ -55,7 +55,7 @@ describe 'Search bar', js: true, feature: true do
it 'hides after clicked' do it 'hides after clicked' do
filtered_search.set('a') filtered_search.set('a')
find('.filtered-search-input-container .clear-search').click find('.filtered-search-box .clear-search').click
expect(page).to have_css('.clear-search', visible: false) expect(page).to have_css('.clear-search', visible: false)
end end
...@@ -81,7 +81,7 @@ describe 'Search bar', js: true, feature: true do ...@@ -81,7 +81,7 @@ describe 'Search bar', js: true, feature: true do
expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to eq(1) expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to eq(1)
find('.filtered-search-input-container .clear-search').click find('.filtered-search-box .clear-search').click
filtered_search.click filtered_search.click
expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to eq(original_size) expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to eq(original_size)
...@@ -96,7 +96,7 @@ describe 'Search bar', js: true, feature: true do ...@@ -96,7 +96,7 @@ describe 'Search bar', js: true, feature: true do
expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to eq(0) expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to eq(0)
find('.filtered-search-input-container .clear-search').click find('.filtered-search-box .clear-search').click
filtered_search.click filtered_search.click
expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to be > 0 expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to be > 0
......
...@@ -14,7 +14,7 @@ feature 'Merge requests filter clear button', feature: true, js: true do ...@@ -14,7 +14,7 @@ feature 'Merge requests filter clear button', feature: true, js: true do
let!(:mr2) { create(:merge_request, title: "Bugfix1", source_project: project, target_project: project, source_branch: "Bugfix1") } let!(:mr2) { create(:merge_request, title: "Bugfix1", source_project: project, target_project: project, source_branch: "Bugfix1") }
let(:merge_request_css) { '.merge-request' } let(:merge_request_css) { '.merge-request' }
let(:clear_search_css) { '.filtered-search-input-container .clear-search' } let(:clear_search_css) { '.filtered-search-box .clear-search' }
before do before do
mr2.labels << bug mr2.labels << bug
......
import Vue from 'vue';
import eventHub from '~/filtered_search/event_hub';
import RecentSearchesDropdownContent from '~/filtered_search/components/recent_searches_dropdown_content';
const createComponent = (propsData) => {
const Component = Vue.extend(RecentSearchesDropdownContent);
return new Component({
el: document.createElement('div'),
propsData,
});
};
// Remove all the newlines and whitespace from the formatted markup
const trimMarkupWhitespace = text => text.replace(/(\n|\s)+/gm, ' ').trim();
describe('RecentSearchesDropdownContent', () => {
const propsDataWithoutItems = {
items: [],
};
const propsDataWithItems = {
items: [
'foo',
'author:@root label:~foo bar',
],
};
let vm;
afterEach(() => {
if (vm) {
vm.$destroy();
}
});
describe('with no items', () => {
let el;
beforeEach(() => {
vm = createComponent(propsDataWithoutItems);
el = vm.$el;
});
it('should render empty state', () => {
expect(el.querySelector('.dropdown-info-note')).toBeDefined();
const items = el.querySelectorAll('.filtered-search-history-dropdown-item');
expect(items.length).toEqual(propsDataWithoutItems.items.length);
});
});
describe('with items', () => {
let el;
beforeEach(() => {
vm = createComponent(propsDataWithItems);
el = vm.$el;
});
it('should render clear recent searches button', () => {
expect(el.querySelector('.filtered-search-history-clear-button')).toBeDefined();
});
it('should render recent search items', () => {
const items = el.querySelectorAll('.filtered-search-history-dropdown-item');
expect(items.length).toEqual(propsDataWithItems.items.length);
expect(trimMarkupWhitespace(items[0].querySelector('.filtered-search-history-dropdown-search-token').textContent)).toEqual('foo');
const item1Tokens = items[1].querySelectorAll('.filtered-search-history-dropdown-token');
expect(item1Tokens.length).toEqual(2);
expect(item1Tokens[0].querySelector('.name').textContent).toEqual('author:');
expect(item1Tokens[0].querySelector('.value').textContent).toEqual('@root');
expect(item1Tokens[1].querySelector('.name').textContent).toEqual('label:');
expect(item1Tokens[1].querySelector('.value').textContent).toEqual('~foo');
expect(trimMarkupWhitespace(items[1].querySelector('.filtered-search-history-dropdown-search-token').textContent)).toEqual('bar');
});
});
describe('computed', () => {
describe('processedItems', () => {
it('with items', () => {
vm = createComponent(propsDataWithItems);
const processedItems = vm.processedItems;
expect(processedItems.length).toEqual(2);
expect(processedItems[0].text).toEqual(propsDataWithItems.items[0]);
expect(processedItems[0].tokens).toEqual([]);
expect(processedItems[0].searchToken).toEqual('foo');
expect(processedItems[1].text).toEqual(propsDataWithItems.items[1]);
expect(processedItems[1].tokens.length).toEqual(2);
expect(processedItems[1].tokens[0].prefix).toEqual('author:');
expect(processedItems[1].tokens[0].suffix).toEqual('@root');
expect(processedItems[1].tokens[1].prefix).toEqual('label:');
expect(processedItems[1].tokens[1].suffix).toEqual('~foo');
expect(processedItems[1].searchToken).toEqual('bar');
});
it('with no items', () => {
vm = createComponent(propsDataWithoutItems);
const processedItems = vm.processedItems;
expect(processedItems.length).toEqual(0);
});
});
describe('hasItems', () => {
it('with items', () => {
vm = createComponent(propsDataWithItems);
const hasItems = vm.hasItems;
expect(hasItems).toEqual(true);
});
it('with no items', () => {
vm = createComponent(propsDataWithoutItems);
const hasItems = vm.hasItems;
expect(hasItems).toEqual(false);
});
});
});
describe('methods', () => {
describe('onItemActivated', () => {
let onRecentSearchesItemSelectedSpy;
beforeEach(() => {
onRecentSearchesItemSelectedSpy = jasmine.createSpy('spy');
eventHub.$on('recentSearchesItemSelected', onRecentSearchesItemSelectedSpy);
vm = createComponent(propsDataWithItems);
});
afterEach(() => {
eventHub.$off('recentSearchesItemSelected', onRecentSearchesItemSelectedSpy);
});
it('emits event', () => {
expect(onRecentSearchesItemSelectedSpy).not.toHaveBeenCalled();
vm.onItemActivated('something');
expect(onRecentSearchesItemSelectedSpy).toHaveBeenCalledWith('something');
});
});
describe('onRequestClearRecentSearches', () => {
let onRequestClearRecentSearchesSpy;
beforeEach(() => {
onRequestClearRecentSearchesSpy = jasmine.createSpy('spy');
eventHub.$on('requestClearRecentSearches', onRequestClearRecentSearchesSpy);
vm = createComponent(propsDataWithItems);
});
afterEach(() => {
eventHub.$off('requestClearRecentSearches', onRequestClearRecentSearchesSpy);
});
it('emits event', () => {
expect(onRequestClearRecentSearchesSpy).not.toHaveBeenCalled();
vm.onRequestClearRecentSearches({ stopPropagation: () => {} });
expect(onRequestClearRecentSearchesSpy).toHaveBeenCalled();
});
});
});
});
...@@ -29,7 +29,7 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper ...@@ -29,7 +29,7 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper
beforeEach(() => { beforeEach(() => {
setFixtures(` setFixtures(`
<div class="filtered-search-input-container"> <div class="filtered-search-box">
<form> <form>
<ul class="tokens-container list-unstyled"> <ul class="tokens-container list-unstyled">
${FilteredSearchSpecHelper.createInputHTML(placeholder)} ${FilteredSearchSpecHelper.createInputHTML(placeholder)}
...@@ -264,12 +264,12 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper ...@@ -264,12 +264,12 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper
describe('toggleInputContainerFocus', () => { describe('toggleInputContainerFocus', () => {
it('toggles on focus', () => { it('toggles on focus', () => {
input.focus(); input.focus();
expect(document.querySelector('.filtered-search-input-container').classList.contains('focus')).toEqual(true); expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual(true);
}); });
it('toggles on blur', () => { it('toggles on blur', () => {
input.blur(); input.blur();
expect(document.querySelector('.filtered-search-input-container').classList.contains('focus')).toEqual(false); expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual(false);
}); });
}); });
}); });
......
import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
describe('RecentSearchesService', () => {
let service;
beforeEach(() => {
service = new RecentSearchesService();
window.localStorage.removeItem(service.localStorageKey);
});
describe('fetch', () => {
it('should default to empty array', (done) => {
const fetchItemsPromise = service.fetch();
fetchItemsPromise
.then((items) => {
expect(items).toEqual([]);
done();
})
.catch((err) => {
done.fail('Shouldn\'t reject with empty localStorage key', err);
});
});
it('should reject when unable to parse', (done) => {
window.localStorage.setItem(service.localStorageKey, 'fail');
const fetchItemsPromise = service.fetch();
fetchItemsPromise
.catch(() => {
done();
});
});
it('should return items from localStorage', (done) => {
window.localStorage.setItem(service.localStorageKey, '["foo", "bar"]');
const fetchItemsPromise = service.fetch();
fetchItemsPromise
.then((items) => {
expect(items).toEqual(['foo', 'bar']);
done();
});
});
});
describe('setRecentSearches', () => {
it('should save things in localStorage', () => {
const items = ['foo', 'bar'];
service.save(items);
const newLocalStorageValue =
window.localStorage.getItem(service.localStorageKey);
expect(JSON.parse(newLocalStorageValue)).toEqual(items);
});
});
});
import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store';
describe('RecentSearchesStore', () => {
let store;
beforeEach(() => {
store = new RecentSearchesStore();
});
describe('addRecentSearch', () => {
it('should add to the front of the list', () => {
store.addRecentSearch('foo');
store.addRecentSearch('bar');
expect(store.state.recentSearches).toEqual(['bar', 'foo']);
});
it('should deduplicate', () => {
store.addRecentSearch('foo');
store.addRecentSearch('bar');
store.addRecentSearch('foo');
expect(store.state.recentSearches).toEqual(['foo', 'bar']);
});
it('only keeps track of 5 items', () => {
store.addRecentSearch('1');
store.addRecentSearch('2');
store.addRecentSearch('3');
store.addRecentSearch('4');
store.addRecentSearch('5');
store.addRecentSearch('6');
store.addRecentSearch('7');
expect(store.state.recentSearches).toEqual(['7', '6', '5', '4', '3']);
});
});
describe('setRecentSearches', () => {
it('should override list', () => {
store.setRecentSearches([
'foo',
'bar',
]);
store.setRecentSearches([
'baz',
'qux',
]);
expect(store.state.recentSearches).toEqual(['baz', 'qux']);
});
it('only keeps track of 5 items', () => {
store.setRecentSearches(['1', '2', '3', '4', '5', '6', '7']);
expect(store.state.recentSearches).toEqual(['1', '2', '3', '4', '5']);
});
});
});
...@@ -86,7 +86,19 @@ describe Gitlab::Elastic::Indexer do ...@@ -86,7 +86,19 @@ describe Gitlab::Elastic::Indexer do
end end
end end
def expect_popen(*with) context 'experimental indexer present' do
before do
stub_application_setting(elasticsearch_experimental_indexer: true)
end
it 'uses the experimental indexer' do
expect_popen.with(['gitlab-elasticsearch-indexer', anything, anything], anything, anything).and_return(popen_success)
indexer.run
end
end
def expect_popen
expect(Gitlab::Popen).to receive(:popen) expect(Gitlab::Popen).to receive(:popen)
end end
......
...@@ -54,6 +54,7 @@ describe Gitlab::UsageData do ...@@ -54,6 +54,7 @@ describe Gitlab::UsageData do
milestones milestones
notes notes
projects projects
projects_prometheus_active
pages_domains pages_domains
protected_branches protected_branches
releases releases
......
...@@ -25,6 +25,20 @@ describe AwardEmoji, models: true do ...@@ -25,6 +25,20 @@ describe AwardEmoji, models: true do
expect(new_award).not_to be_valid expect(new_award).not_to be_valid
end end
# Assume User A and User B both created award emoji of the same name
# on the same awardable. When User A is deleted, User A's award emoji
# is moved to the ghost user. When User B is deleted, User B's award emoji
# also needs to be moved to the ghost user - this cannot happen unless
# the uniqueness validation is disabled for ghost users.
it "allows duplicate award emoji for ghost users" do
user = create(:user, :ghost)
issue = create(:issue)
create(:award_emoji, user: user, awardable: issue)
new_award = build(:award_emoji, user: user, awardable: issue)
expect(new_award).to be_valid
end
end end
end end
end end
...@@ -28,7 +28,6 @@ describe User, models: true do ...@@ -28,7 +28,6 @@ describe User, models: true do
it { is_expected.to have_many(:merge_requests).dependent(:destroy) } it { is_expected.to have_many(:merge_requests).dependent(:destroy) }
it { is_expected.to have_many(:assigned_merge_requests).dependent(:nullify) } it { is_expected.to have_many(:assigned_merge_requests).dependent(:nullify) }
it { is_expected.to have_many(:identities).dependent(:destroy) } it { is_expected.to have_many(:identities).dependent(:destroy) }
it { is_expected.to have_one(:abuse_report) }
it { is_expected.to have_many(:spam_logs).dependent(:destroy) } it { is_expected.to have_many(:spam_logs).dependent(:destroy) }
it { is_expected.to have_many(:todos).dependent(:destroy) } it { is_expected.to have_many(:todos).dependent(:destroy) }
it { is_expected.to have_many(:award_emoji).dependent(:destroy) } it { is_expected.to have_many(:award_emoji).dependent(:destroy) }
...@@ -38,6 +37,34 @@ describe User, models: true do ...@@ -38,6 +37,34 @@ describe User, models: true do
it { is_expected.to have_many(:pipelines).dependent(:nullify) } it { is_expected.to have_many(:pipelines).dependent(:nullify) }
it { is_expected.to have_many(:chat_names).dependent(:destroy) } it { is_expected.to have_many(:chat_names).dependent(:destroy) }
it { is_expected.to have_many(:uploads).dependent(:destroy) } it { is_expected.to have_many(:uploads).dependent(:destroy) }
it { is_expected.to have_many(:reported_abuse_reports).dependent(:destroy).class_name('AbuseReport') }
describe "#abuse_report" do
let(:current_user) { create(:user) }
let(:other_user) { create(:user) }
it { is_expected.to have_one(:abuse_report) }
it "refers to the abuse report whose user_id is the current user" do
abuse_report = create(:abuse_report, reporter: other_user, user: current_user)
expect(current_user.abuse_report).to eq(abuse_report)
end
it "does not refer to the abuse report whose reporter_id is the current user" do
create(:abuse_report, reporter: current_user, user: other_user)
expect(current_user.abuse_report).to be_nil
end
it "does not update the user_id of an abuse report when the user is updated" do
abuse_report = create(:abuse_report, reporter: current_user, user: other_user)
current_user.block
expect(abuse_report.reload.user).to eq(other_user)
end
end
describe '#group_members' do describe '#group_members' do
it 'does not include group memberships for which user is a requester' do it 'does not include group memberships for which user is a requester' do
......
...@@ -46,43 +46,47 @@ describe Users::DestroyService, services: true do ...@@ -46,43 +46,47 @@ describe Users::DestroyService, services: true do
project.add_developer(user) project.add_developer(user)
end end
context "for an issue the user has created" do context "for an issue the user was assigned to" do
let!(:issue) { create(:issue, project: project, author: user) } let!(:issue) { create(:issue, project: project, assignee: user) }
before do before do
service.execute(user) service.execute(user)
end end
it 'does not delete the issue' do it 'does not delete issues the user is assigned to' do
expect(Issue.find_by_id(issue.id)).to be_present expect(Issue.find_by_id(issue.id)).to be_present
end end
it 'migrates the issue so that the "Ghost User" is the issue owner' do it 'migrates the issue so that it is "Unassigned"' do
migrated_issue = Issue.find_by_id(issue.id) migrated_issue = Issue.find_by_id(issue.id)
expect(migrated_issue.author).to eq(User.ghost) expect(migrated_issue.assignee).to be_nil
end end
it 'blocks the user before migrating issues to the "Ghost User' do
expect(user).to be_blocked
end end
end end
context "for an issue the user was assigned to" do context "a deleted user's merge_requests" do
let!(:issue) { create(:issue, project: project, assignee: user) } let(:project) { create(:project) }
before do
project.add_developer(user)
end
context "for an merge request the user was assigned to" do
let!(:merge_request) { create(:merge_request, source_project: project, assignee: user) }
before do before do
service.execute(user) service.execute(user)
end end
it 'does not delete issues the user is assigned to' do it 'does not delete merge requests the user is assigned to' do
expect(Issue.find_by_id(issue.id)).to be_present expect(MergeRequest.find_by_id(merge_request.id)).to be_present
end end
it 'migrates the issue so that it is "Unassigned"' do it 'migrates the merge request so that it is "Unassigned"' do
migrated_issue = Issue.find_by_id(issue.id) migrated_merge_request = MergeRequest.find_by_id(merge_request.id)
expect(migrated_issue.assignee).to be_nil expect(migrated_merge_request.assignee).to be_nil
end end
end end
end end
...@@ -141,5 +145,13 @@ describe Users::DestroyService, services: true do ...@@ -141,5 +145,13 @@ describe Users::DestroyService, services: true do
expect(User.exists?(user.id)).to be(false) expect(User.exists?(user.id)).to be(false)
end end
end end
context "migrating associated records" do
it 'delegates to the `MigrateToGhostUser` service to move associated records to the ghost user' do
expect_any_instance_of(Users::MigrateToGhostUserService).to receive(:execute).once
service.execute(user)
end
end
end end
end end
require 'spec_helper'
describe Users::MigrateToGhostUserService, services: true do
let!(:user) { create(:user) }
let!(:project) { create(:project) }
let(:service) { described_class.new(user) }
context "migrating a user's associated records to the ghost user" do
context 'issues' do
include_examples "migrating a deleted user's associated records to the ghost user", Issue do
let(:created_record) { create(:issue, project: project, author: user) }
let(:assigned_record) { create(:issue, project: project, assignee: user) }
end
end
context 'merge requests' do
include_examples "migrating a deleted user's associated records to the ghost user", MergeRequest do
let(:created_record) { create(:merge_request, source_project: project, author: user, target_branch: "first") }
let(:assigned_record) { create(:merge_request, source_project: project, assignee: user, target_branch: 'second') }
end
end
context 'notes' do
include_examples "migrating a deleted user's associated records to the ghost user", Note do
let(:created_record) { create(:note, project: project, author: user) }
end
end
context 'abuse reports' do
include_examples "migrating a deleted user's associated records to the ghost user", AbuseReport do
let(:created_record) { create(:abuse_report, reporter: user, user: create(:user)) }
end
end
context 'award emoji' do
include_examples "migrating a deleted user's associated records to the ghost user", AwardEmoji do
let(:created_record) { create(:award_emoji, user: user) }
let(:author_alias) { :user }
context "when the awardable already has an award emoji of the same name assigned to the ghost user" do
let(:awardable) { create(:issue) }
let!(:existing_award_emoji) { create(:award_emoji, user: User.ghost, name: "thumbsup", awardable: awardable) }
let!(:award_emoji) { create(:award_emoji, user: user, name: "thumbsup", awardable: awardable) }
it "migrates the award emoji regardless" do
service.execute
migrated_record = AwardEmoji.find_by_id(award_emoji.id)
expect(migrated_record.user).to eq(User.ghost)
end
it "does not leave the migrated award emoji in an invalid state" do
service.execute
migrated_record = AwardEmoji.find_by_id(award_emoji.id)
expect(migrated_record).to be_valid
end
end
end
end
end
end
...@@ -30,7 +30,7 @@ module FilteredSearchHelpers ...@@ -30,7 +30,7 @@ module FilteredSearchHelpers
end end
def clear_search_field def clear_search_field
find('.filtered-search-input-container .clear-search').click find('.filtered-search-box .clear-search').click
end end
def reset_filters def reset_filters
...@@ -51,7 +51,7 @@ module FilteredSearchHelpers ...@@ -51,7 +51,7 @@ module FilteredSearchHelpers
# Iterates through each visual token inside # Iterates through each visual token inside
# .tokens-container to make sure the correct names and values are rendered # .tokens-container to make sure the correct names and values are rendered
def expect_tokens(tokens) def expect_tokens(tokens)
page.find '.filtered-search-input-container .tokens-container' do page.find '.filtered-search-box .tokens-container' do
page.all(:css, '.tokens-container li').each_with_index do |el, index| page.all(:css, '.tokens-container li').each_with_index do |el, index|
token_name = tokens[index][:name] token_name = tokens[index][:name]
token_value = tokens[index][:value] token_value = tokens[index][:value]
...@@ -71,4 +71,18 @@ module FilteredSearchHelpers ...@@ -71,4 +71,18 @@ module FilteredSearchHelpers
def get_filtered_search_placeholder def get_filtered_search_placeholder
find('.filtered-search')['placeholder'] find('.filtered-search')['placeholder']
end end
def remove_recent_searches
execute_script('window.localStorage.removeItem(\'issue-recent-searches\');')
end
def set_recent_searches(input)
execute_script("window.localStorage.setItem('issue-recent-searches', '#{input}');")
end
def wait_for_filtered_search(text)
Timeout.timeout(Capybara.default_max_wait_time) do
loop until find('.filtered-search').value.strip == text
end
end
end end
require "spec_helper"
shared_examples "migrating a deleted user's associated records to the ghost user" do |record_class|
record_class_name = record_class.to_s.titleize.downcase
let(:project) { create(:project) }
before do
project.add_developer(user)
end
context "for a #{record_class_name} the user has created" do
let!(:record) { created_record }
it "does not delete the #{record_class_name}" do
service.execute
expect(record_class.find_by_id(record.id)).to be_present
end
it "migrates the #{record_class_name} so that the 'Ghost User' is the #{record_class_name} owner" do
service.execute
migrated_record = record_class.find_by_id(record.id)
if migrated_record.respond_to?(:author)
expect(migrated_record.author).to eq(User.ghost)
else
expect(migrated_record.send(author_alias)).to eq(User.ghost)
end
end
it "blocks the user before migrating #{record_class_name}s to the 'Ghost User'" do
service.execute
expect(user).to be_blocked
end
end
end
...@@ -40,6 +40,12 @@ describe GeoRepositoryFetchWorker do ...@@ -40,6 +40,12 @@ describe GeoRepositoryFetchWorker do
perform perform
end end
it 'does not raise exception when git failures occurs' do
expect_any_instance_of(Repository).to receive(:fetch_geo_mirror).and_raise(Gitlab::Shell::Error)
expect { perform }.not_to raise_error
end
end end
def perform def perform
......
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