Commit 7c73aeb9 authored by Douwe Maan's avatar Douwe Maan

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

CE upstream: Thursday

Closes gitaly#174

See merge request !1683
parents 5d5d3d1c 6f7be97e
......@@ -264,7 +264,6 @@ rake karma:
cache:
paths:
- vendor/ruby
- node_modules
stage: test
<<: *use-db
<<: *dedicated-runner
......@@ -363,9 +362,6 @@ coverage:
lint:javascript:
<<: *dedicated-runner
cache:
paths:
- node_modules/
stage: test
before_script: []
script:
......@@ -373,9 +369,6 @@ lint:javascript:
lint:javascript:report:
<<: *dedicated-runner
cache:
paths:
- node_modules/
stage: post-test
before_script: []
script:
......
import CANCELED_SVG from 'icons/_icon_status_canceled_borderless.svg';
import CREATED_SVG from 'icons/_icon_status_created_borderless.svg';
import FAILED_SVG from 'icons/_icon_status_failed_borderless.svg';
import MANUAL_SVG from 'icons/_icon_status_manual_borderless.svg';
import PENDING_SVG from 'icons/_icon_status_pending_borderless.svg';
import RUNNING_SVG from 'icons/_icon_status_running_borderless.svg';
import SKIPPED_SVG from 'icons/_icon_status_skipped_borderless.svg';
import SUCCESS_SVG from 'icons/_icon_status_success_borderless.svg';
import WARNING_SVG from 'icons/_icon_status_warning_borderless.svg';
const StatusIconEntityMap = {
icon_status_canceled: CANCELED_SVG,
icon_status_created: CREATED_SVG,
icon_status_failed: FAILED_SVG,
icon_status_manual: MANUAL_SVG,
icon_status_pending: PENDING_SVG,
icon_status_running: RUNNING_SVG,
icon_status_skipped: SKIPPED_SVG,
icon_status_success: SUCCESS_SVG,
icon_status_warning: WARNING_SVG,
};
export {
CANCELED_SVG,
CREATED_SVG,
FAILED_SVG,
MANUAL_SVG,
PENDING_SVG,
RUNNING_SVG,
SKIPPED_SVG,
SUCCESS_SVG,
WARNING_SVG,
StatusIconEntityMap as default,
};
......@@ -19,7 +19,7 @@ const ResolveBtn = Vue.extend({
data: function () {
return {
discussions: CommentsStore.state,
loading: false,
loading: false
};
},
watch: {
......
......@@ -156,13 +156,13 @@ const ShortcutsBlob = require('./shortcuts_blob');
case 'projects:milestones:new':
case 'projects:milestones:edit':
case 'projects:milestones:update':
case 'groups:milestones:new':
case 'groups:milestones:edit':
case 'groups:milestones:update':
new ZenMode();
new gl.DueDateSelectors();
new gl.GLForm($('.milestone-form'));
break;
case 'groups:milestones:new':
new ZenMode();
break;
case 'projects:compare:show':
new gl.Diff();
break;
......@@ -389,9 +389,8 @@ const ShortcutsBlob = require('./shortcuts_blob');
case 'admin':
new Admin();
switch (path[1]) {
case 'application_settings':
case 'cohorts':
new gl.ApplicationSettings();
new gl.UsagePing();
break;
case 'groups':
new UsersSelect();
......
......@@ -2,10 +2,12 @@ const DATA_TRIGGER = 'data-dropdown-trigger';
const DATA_DROPDOWN = 'data-dropdown';
const SELECTED_CLASS = 'droplab-item-selected';
const ACTIVE_CLASS = 'droplab-item-active';
const IGNORE_CLASS = 'droplab-item-ignore';
export {
DATA_TRIGGER,
DATA_DROPDOWN,
SELECTED_CLASS,
ACTIVE_CLASS,
IGNORE_CLASS,
};
/* eslint-disable */
import utils from './utils';
import { SELECTED_CLASS } from './constants';
import { SELECTED_CLASS, IGNORE_CLASS } from './constants';
var DropDown = function(list) {
this.currentIndex = 0;
......@@ -36,6 +36,7 @@ Object.assign(DropDown.prototype, {
clickEvent: function(e) {
if (e.target.tagName === 'UL') return;
if (e.target.classList.contains(IGNORE_CLASS)) return;
var selected = utils.closest(e.target, 'LI');
if (!selected) return;
......
......@@ -38,6 +38,9 @@ window.DropzoneInput = (function() {
"opacity": 0,
"display": "none"
});
if (!project_uploads_path) return;
dropzone = form_dropzone.dropzone({
url: project_uploads_path,
dictDefaultMessage: "",
......@@ -133,8 +136,6 @@ window.DropzoneInput = (function() {
const textarea = child.get(0);
caretStart = textarea.selectionStart;
caretEnd = textarea.selectionEnd;
caretStart = textarea.selectionStart;
caretEnd = textarea.selectionEnd;
textEnd = $(child).val().length;
beforeSelection = $(child).val().substring(0, caretStart);
afterSelection = $(child).val().substring(caretEnd, textEnd);
......
<script>
/**
* Renders the external url link in environments table.
*/
......@@ -5,7 +6,7 @@ export default {
props: {
externalUrl: {
type: String,
default: '',
required: true,
},
},
......@@ -14,17 +15,19 @@ export default {
return 'Open';
},
},
template: `
<a
class="btn external-url has-tooltip"
data-container="body"
:href="externalUrl"
target="_blank"
rel="noopener noreferrer nofollow"
:title="title"
:aria-label="title">
<i class="fa fa-external-link" aria-hidden="true"></i>
</a>
`,
};
</script>
<template>
<a
class="btn external-url has-tooltip"
data-container="body"
target="_blank"
rel="noopener noreferrer nofollow"
:title="title"
:aria-label="title"
:href="externalUrl">
<i
class="fa fa-external-link"
aria-hidden="true" />
</a>
</template>
......@@ -7,10 +7,10 @@
import Timeago from 'timeago.js';
import '../../lib/utils/text_utility';
import ActionsComponent from './environment_actions';
import ExternalUrlComponent from './environment_external_url';
import StopComponent from './environment_stop';
import ExternalUrlComponent from './environment_external_url.vue';
import StopComponent from './environment_stop.vue';
import RollbackComponent from './environment_rollback';
import TerminalButtonComponent from './environment_terminal_button';
import TerminalButtonComponent from './environment_terminal_button.vue';
import MonitoringButtonComponent from './environment_monitoring';
import CommitComponent from '../../vue_shared/components/commit';
import eventHub from '../event_hub';
......
......@@ -21,7 +21,6 @@ export default {
class="btn monitoring-url has-tooltip"
data-container="body"
:href="monitoringUrl"
target="_blank"
rel="noopener noreferrer nofollow"
:title="title"
:aria-label="title">
......
<script>
/* global Flash */
/* eslint-disable no-new, no-alert */
/**
......@@ -51,17 +52,23 @@ export default {
}
},
},
template: `
<button type="button"
class="btn stop-env-link has-tooltip"
data-container="body"
@click="onClick"
:disabled="isLoading"
:title="title"
:aria-label="title">
<i class="fa fa-stop stop-env-icon" aria-hidden="true"></i>
<i v-if="isLoading" class="fa fa-spinner fa-spin" aria-hidden="true"></i>
</button>
`,
};
</script>
<template>
<button
type="button"
class="btn stop-env-link has-tooltip"
data-container="body"
@click="onClick"
:disabled="isLoading"
:title="title"
:aria-label="title">
<i
class="fa fa-stop stop-env-icon"
aria-hidden="true" />
<i
v-if="isLoading"
class="fa fa-spinner fa-spin"
aria-hidden="true" />
</button>
</template>
<script>
/**
* Renders a terminal button to open a web terminal.
* Used in environments table.
......@@ -24,14 +25,15 @@ export default {
return 'Terminal';
},
},
template: `
<a class="btn terminal-button has-tooltip"
data-container="body"
:title="title"
:aria-label="title"
:href="terminalPath">
${terminalIconSvg}
</a>
`,
};
</script>
<template>
<a
class="btn terminal-button has-tooltip"
data-container="body"
:title="title"
:aria-label="title"
:href="terminalPath"
v-html="terminalIconSvg">
</a>
</template>
......@@ -2,82 +2,80 @@ import Filter from '~/droplab/plugins/filter';
require('./filtered_search_dropdown');
(() => {
class DropdownHint extends gl.FilteredSearchDropdown {
constructor(droplab, dropdown, input, filter) {
super(droplab, dropdown, input, filter);
this.config = {
Filter: {
template: 'hint',
filterFunction: gl.DropdownUtils.filterHint.bind(null, input),
},
};
}
itemClicked(e) {
const { selected } = e.detail;
class DropdownHint extends gl.FilteredSearchDropdown {
constructor(droplab, dropdown, input, filter) {
super(droplab, dropdown, input, filter);
this.config = {
Filter: {
template: 'hint',
filterFunction: gl.DropdownUtils.filterHint.bind(null, input),
},
};
}
if (selected.tagName === 'LI') {
if (selected.hasAttribute('data-value')) {
this.dismissDropdown();
} else if (selected.getAttribute('data-action') === 'submit') {
this.dismissDropdown();
this.dispatchFormSubmitEvent();
} else {
const token = selected.querySelector('.js-filter-hint').innerText.trim();
const tag = selected.querySelector('.js-filter-tag').innerText.trim();
itemClicked(e) {
const { selected } = e.detail;
if (tag.length) {
// Get previous input values in the input field and convert them into visual tokens
const previousInputValues = this.input.value.split(' ');
const searchTerms = [];
if (selected.tagName === 'LI') {
if (selected.hasAttribute('data-value')) {
this.dismissDropdown();
} else if (selected.getAttribute('data-action') === 'submit') {
this.dismissDropdown();
this.dispatchFormSubmitEvent();
} else {
const token = selected.querySelector('.js-filter-hint').innerText.trim();
const tag = selected.querySelector('.js-filter-tag').innerText.trim();
previousInputValues.forEach((value, index) => {
searchTerms.push(value);
if (tag.length) {
// Get previous input values in the input field and convert them into visual tokens
const previousInputValues = this.input.value.split(' ');
const searchTerms = [];
if (index === previousInputValues.length - 1
&& token.indexOf(value.toLowerCase()) !== -1) {
searchTerms.pop();
}
});
previousInputValues.forEach((value, index) => {
searchTerms.push(value);
if (searchTerms.length > 0) {
gl.FilteredSearchVisualTokens.addSearchVisualToken(searchTerms.join(' '));
if (index === previousInputValues.length - 1
&& token.indexOf(value.toLowerCase()) !== -1) {
searchTerms.pop();
}
});
gl.FilteredSearchDropdownManager.addWordToInput(token.replace(':', ''), '', false, this.container);
if (searchTerms.length > 0) {
gl.FilteredSearchVisualTokens.addSearchVisualToken(searchTerms.join(' '));
}
this.dismissDropdown();
this.dispatchInputEvent();
gl.FilteredSearchDropdownManager.addWordToInput(token.replace(':', ''), '', false, this.container);
}
this.dismissDropdown();
this.dispatchInputEvent();
}
}
}
renderContent() {
const dropdownData = [];
renderContent() {
const dropdownData = [];
[].forEach.call(this.input.closest('.filtered-search-box-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => {
const { icon, hint, tag, type } = dropdownMenu.dataset;
if (icon && hint && tag) {
dropdownData.push(
Object.assign({
icon: `fa-${icon}`,
hint,
tag: `&lt;${tag}&gt;`,
}, type && { type }),
);
}
});
[].forEach.call(this.input.closest('.filtered-search-box-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => {
const { icon, hint, tag, type } = dropdownMenu.dataset;
if (icon && hint && tag) {
dropdownData.push(
Object.assign({
icon: `fa-${icon}`,
hint,
tag: `&lt;${tag}&gt;`,
}, type && { type }),
);
}
});
this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config);
this.droplab.setData(this.hookId, dropdownData);
}
this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config);
this.droplab.setData(this.hookId, dropdownData);
}
init() {
this.droplab.addHook(this.input, this.dropdown, [Filter], this.config).init();
}
init() {
this.droplab.addHook(this.input, this.dropdown, [Filter], this.config).init();
}
}
window.gl = window.gl || {};
gl.DropdownHint = DropdownHint;
})();
window.gl = window.gl || {};
gl.DropdownHint = DropdownHint;
......@@ -5,48 +5,46 @@ import Filter from '~/droplab/plugins/filter';
require('./filtered_search_dropdown');
(() => {
class DropdownNonUser extends gl.FilteredSearchDropdown {
constructor(droplab, dropdown, input, filter, endpoint, symbol) {
super(droplab, dropdown, input, filter);
this.symbol = symbol;
this.config = {
Ajax: {
endpoint,
method: 'setData',
loadingTemplate: this.loadingTemplate,
onError() {
/* eslint-disable no-new */
new Flash('An error occured fetching the dropdown data.');
/* eslint-enable no-new */
},
class DropdownNonUser extends gl.FilteredSearchDropdown {
constructor(droplab, dropdown, input, filter, endpoint, symbol) {
super(droplab, dropdown, input, filter);
this.symbol = symbol;
this.config = {
Ajax: {
endpoint,
method: 'setData',
loadingTemplate: this.loadingTemplate,
onError() {
/* eslint-disable no-new */
new Flash('An error occured fetching the dropdown data.');
/* eslint-enable no-new */
},
Filter: {
filterFunction: gl.DropdownUtils.filterWithSymbol.bind(null, this.symbol, input),
template: 'title',
},
};
}
},
Filter: {
filterFunction: gl.DropdownUtils.filterWithSymbol.bind(null, this.symbol, input),
template: 'title',
},
};
}
itemClicked(e) {
super.itemClicked(e, (selected) => {
const title = selected.querySelector('.js-data-value').innerText.trim();
return `${this.symbol}${gl.DropdownUtils.getEscapedText(title)}`;
});
}
itemClicked(e) {
super.itemClicked(e, (selected) => {
const title = selected.querySelector('.js-data-value').innerText.trim();
return `${this.symbol}${gl.DropdownUtils.getEscapedText(title)}`;
});
}
renderContent(forceShowList = false) {
this.droplab
.changeHookList(this.hookId, this.dropdown, [Ajax, Filter], this.config);
super.renderContent(forceShowList);
}
renderContent(forceShowList = false) {
this.droplab
.changeHookList(this.hookId, this.dropdown, [Ajax, Filter], this.config);
super.renderContent(forceShowList);
}
init() {
this.droplab
.addHook(this.input, this.dropdown, [Ajax, Filter], this.config).init();
}
init() {
this.droplab
.addHook(this.input, this.dropdown, [Ajax, Filter], this.config).init();
}
}
window.gl = window.gl || {};
gl.DropdownNonUser = DropdownNonUser;
})();
window.gl = window.gl || {};
gl.DropdownNonUser = DropdownNonUser;
......@@ -4,69 +4,67 @@ import AjaxFilter from '~/droplab/plugins/ajax_filter';
require('./filtered_search_dropdown');
(() => {
class DropdownUser extends gl.FilteredSearchDropdown {
constructor(droplab, dropdown, input, filter) {
super(droplab, dropdown, input, filter);
this.config = {
AjaxFilter: {
endpoint: `${gon.relative_url_root || ''}/autocomplete/users.json`,
searchKey: 'search',
params: {
per_page: 20,
active: true,
project_id: this.getProjectId(),
current_user: true,
},
searchValueFunction: this.getSearchInput.bind(this),
loadingTemplate: this.loadingTemplate,
onError() {
/* eslint-disable no-new */
new Flash('An error occured fetching the dropdown data.');
/* eslint-enable no-new */
},
class DropdownUser extends gl.FilteredSearchDropdown {
constructor(droplab, dropdown, input, filter) {
super(droplab, dropdown, input, filter);
this.config = {
AjaxFilter: {
endpoint: `${gon.relative_url_root || ''}/autocomplete/users.json`,
searchKey: 'search',
params: {
per_page: 20,
active: true,
project_id: this.getProjectId(),
current_user: true,
},
};
}
itemClicked(e) {
super.itemClicked(e,
selected => selected.querySelector('.dropdown-light-content').innerText.trim());
}
renderContent(forceShowList = false) {
this.droplab.changeHookList(this.hookId, this.dropdown, [AjaxFilter], this.config);
super.renderContent(forceShowList);
}
searchValueFunction: this.getSearchInput.bind(this),
loadingTemplate: this.loadingTemplate,
onError() {
/* eslint-disable no-new */
new Flash('An error occured fetching the dropdown data.');
/* eslint-enable no-new */
},
},
};
}
getProjectId() {
return this.input.getAttribute('data-project-id');
}
itemClicked(e) {
super.itemClicked(e,
selected => selected.querySelector('.dropdown-light-content').innerText.trim());
}
getSearchInput() {
const query = gl.DropdownUtils.getSearchInput(this.input);
const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query);
renderContent(forceShowList = false) {
this.droplab.changeHookList(this.hookId, this.dropdown, [AjaxFilter], this.config);
super.renderContent(forceShowList);
}
let value = lastToken || '';
getProjectId() {
return this.input.getAttribute('data-project-id');
}
if (value[0] === '@') {
value = value.slice(1);
}
getSearchInput() {
const query = gl.DropdownUtils.getSearchInput(this.input);
const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query);
// Removes the first character if it is a quotation so that we can search
// with multiple words
if (value[0] === '"' || value[0] === '\'') {
value = value.slice(1);
}
let value = lastToken || '';
return value;
if (value[0] === '@') {
value = value.slice(1);
}
init() {
this.droplab.addHook(this.input, this.dropdown, [AjaxFilter], this.config).init();
// Removes the first character if it is a quotation so that we can search
// with multiple words
if (value[0] === '"' || value[0] === '\'') {
value = value.slice(1);
}
return value;
}
init() {
this.droplab.addHook(this.input, this.dropdown, [AjaxFilter], this.config).init();
}
}
window.gl = window.gl || {};
gl.DropdownUser = DropdownUser;
})();
window.gl = window.gl || {};
gl.DropdownUser = DropdownUser;
import FilteredSearchContainer from './container';
(() => {
class DropdownUtils {
static getEscapedText(text) {
let escapedText = text;
const hasSpace = text.indexOf(' ') !== -1;
const hasDoubleQuote = text.indexOf('"') !== -1;
// Encapsulate value with quotes if it has spaces
// Known side effect: values's with both single and double quotes
// won't escape properly
if (hasSpace) {
if (hasDoubleQuote) {
escapedText = `'${text}'`;
} else {
// Encapsulate singleQuotes or if it hasSpace
escapedText = `"${text}"`;
}
class DropdownUtils {
static getEscapedText(text) {
let escapedText = text;
const hasSpace = text.indexOf(' ') !== -1;
const hasDoubleQuote = text.indexOf('"') !== -1;
// Encapsulate value with quotes if it has spaces
// Known side effect: values's with both single and double quotes
// won't escape properly
if (hasSpace) {
if (hasDoubleQuote) {
escapedText = `'${text}'`;
} else {
// Encapsulate singleQuotes or if it hasSpace
escapedText = `"${text}"`;
}
return escapedText;
}
static filterWithSymbol(filterSymbol, input, item) {
const updatedItem = item;
const searchInput = gl.DropdownUtils.getSearchInput(input);
return escapedText;
}
static filterWithSymbol(filterSymbol, input, item) {
const updatedItem = item;
const searchInput = gl.DropdownUtils.getSearchInput(input);
const title = updatedItem.title.toLowerCase();
let value = searchInput.toLowerCase();
let symbol = '';
const title = updatedItem.title.toLowerCase();
let value = searchInput.toLowerCase();
let symbol = '';
// Remove the symbol for filter
if (value[0] === filterSymbol) {
symbol = value[0];
value = value.slice(1);
}
// Remove the symbol for filter
if (value[0] === filterSymbol) {
symbol = value[0];
value = value.slice(1);
}
// Removes the first character if it is a quotation so that we can search
// with multiple words
if ((value[0] === '"' || value[0] === '\'') && title.indexOf(' ') !== -1) {
value = value.slice(1);
}
// Removes the first character if it is a quotation so that we can search
// with multiple words
if ((value[0] === '"' || value[0] === '\'') && title.indexOf(' ') !== -1) {
value = value.slice(1);
}
// Eg. filterSymbol = ~ for labels
const matchWithoutSymbol = symbol === filterSymbol && title.indexOf(value) !== -1;
const match = title.indexOf(`${symbol}${value}`) !== -1;
// Eg. filterSymbol = ~ for labels
const matchWithoutSymbol = symbol === filterSymbol && title.indexOf(value) !== -1;
const match = title.indexOf(`${symbol}${value}`) !== -1;
updatedItem.droplab_hidden = !match && !matchWithoutSymbol;
updatedItem.droplab_hidden = !match && !matchWithoutSymbol;
return updatedItem;
}
return updatedItem;
static filterHint(input, item) {
const updatedItem = item;
const searchInput = gl.DropdownUtils.getSearchQuery(input);
const { lastToken, tokens } = gl.FilteredSearchTokenizer.processTokens(searchInput);
const lastKey = lastToken.key || lastToken || '';
const allowMultiple = item.type === 'array';
const itemInExistingTokens = tokens.some(t => t.key === item.hint);
if (!allowMultiple && itemInExistingTokens) {
updatedItem.droplab_hidden = true;
} else if (!lastKey || searchInput.split('').last() === ' ') {
updatedItem.droplab_hidden = false;
} else if (lastKey) {
const split = lastKey.split(':');
const tokenName = split[0].split(' ').last();
const match = updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1;
updatedItem.droplab_hidden = tokenName ? match : false;
}
static filterHint(input, item) {
const updatedItem = item;
const searchInput = gl.DropdownUtils.getSearchQuery(input);
const { lastToken, tokens } = gl.FilteredSearchTokenizer.processTokens(searchInput);
const lastKey = lastToken.key || lastToken || '';
const allowMultiple = item.type === 'array';
const itemInExistingTokens = tokens.some(t => t.key === item.hint);
if (!allowMultiple && itemInExistingTokens) {
updatedItem.droplab_hidden = true;
} else if (!lastKey || searchInput.split('').last() === ' ') {
updatedItem.droplab_hidden = false;
} else if (lastKey) {
const split = lastKey.split(':');
const tokenName = split[0].split(' ').last();
const match = updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1;
updatedItem.droplab_hidden = tokenName ? match : false;
}
return updatedItem;
}
static setDataValueIfSelected(filter, selected) {
const dataValue = selected.getAttribute('data-value');
return updatedItem;
if (dataValue) {
gl.FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true);
}
static setDataValueIfSelected(filter, selected) {
const dataValue = selected.getAttribute('data-value');
// Return boolean based on whether it was set
return dataValue !== null;
}
if (dataValue) {
gl.FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true);
}
// Determines the full search query (visual tokens + input)
static getSearchQuery(untilInput = false) {
const container = FilteredSearchContainer.container;
const tokens = [].slice.call(container.querySelectorAll('.tokens-container li'));
const values = [];
// Return boolean based on whether it was set
return dataValue !== null;
if (untilInput) {
const inputIndex = _.findIndex(tokens, t => t.classList.contains('input-token'));
// Add one to include input-token to the tokens array
tokens.splice(inputIndex + 1);
}
// Determines the full search query (visual tokens + input)
static getSearchQuery(untilInput = false) {
const container = FilteredSearchContainer.container;
const tokens = [].slice.call(container.querySelectorAll('.tokens-container li'));
const values = [];
tokens.forEach((token) => {
if (token.classList.contains('js-visual-token')) {
const name = token.querySelector('.name');
const value = token.querySelector('.value');
const symbol = value && value.dataset.symbol ? value.dataset.symbol : '';
let valueText = '';
if (untilInput) {
const inputIndex = _.findIndex(tokens, t => t.classList.contains('input-token'));
// Add one to include input-token to the tokens array
tokens.splice(inputIndex + 1);
}
if (value && value.innerText) {
valueText = value.innerText;
}
tokens.forEach((token) => {
if (token.classList.contains('js-visual-token')) {
const name = token.querySelector('.name');
const value = token.querySelector('.value');
const symbol = value && value.dataset.symbol ? value.dataset.symbol : '';
let valueText = '';
if (value && value.innerText) {
valueText = value.innerText;
}
if (token.className.indexOf('filtered-search-token') !== -1) {
values.push(`${name.innerText.toLowerCase()}:${symbol}${valueText}`);
} else {
values.push(name.innerText);
}
} else if (token.classList.contains('input-token')) {
const { isLastVisualTokenValid } =
gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
const inputValue = input && input.value;
if (isLastVisualTokenValid) {
values.push(inputValue);
} else {
const previous = values.pop();
values.push(`${previous}${inputValue}`);
}
if (token.className.indexOf('filtered-search-token') !== -1) {
values.push(`${name.innerText.toLowerCase()}:${symbol}${valueText}`);
} else {
values.push(name.innerText);
}
});
} else if (token.classList.contains('input-token')) {
const { isLastVisualTokenValid } =
gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
return values
.map(value => value.trim())
.join(' ');
}
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
const inputValue = input && input.value;
static getSearchInput(filteredSearchInput) {
const inputValue = filteredSearchInput.value;
const { right } = gl.DropdownUtils.getInputSelectionPosition(filteredSearchInput);
if (isLastVisualTokenValid) {
values.push(inputValue);
} else {
const previous = values.pop();
values.push(`${previous}${inputValue}`);
}
}
});
return inputValue.slice(0, right);
}
return values
.map(value => value.trim())
.join(' ');
}
static getInputSelectionPosition(input) {
const selectionStart = input.selectionStart;
let inputValue = input.value;
// Replace all spaces inside quote marks with underscores
// (will continue to match entire string until an end quote is found if any)
// This helps with matching the beginning & end of a token:key
inputValue = inputValue.replace(/(('[^']*'{0,1})|("[^"]*"{0,1})|:\s+)/g, str => str.replace(/\s/g, '_'));
// Get the right position for the word selected
// Regex matches first space
let right = inputValue.slice(selectionStart).search(/\s/);
if (right >= 0) {
right += selectionStart;
} else if (right < 0) {
right = inputValue.length;
}
static getSearchInput(filteredSearchInput) {
const inputValue = filteredSearchInput.value;
const { right } = gl.DropdownUtils.getInputSelectionPosition(filteredSearchInput);
// Get the left position for the word selected
// Regex matches last non-whitespace character
let left = inputValue.slice(0, right).search(/\S+$/);
return inputValue.slice(0, right);
}
if (selectionStart === 0) {
left = 0;
} else if (selectionStart === inputValue.length && left < 0) {
left = inputValue.length;
} else if (left < 0) {
left = selectionStart;
}
static getInputSelectionPosition(input) {
const selectionStart = input.selectionStart;
let inputValue = input.value;
// Replace all spaces inside quote marks with underscores
// (will continue to match entire string until an end quote is found if any)
// This helps with matching the beginning & end of a token:key
inputValue = inputValue.replace(/(('[^']*'{0,1})|("[^"]*"{0,1})|:\s+)/g, str => str.replace(/\s/g, '_'));
// Get the right position for the word selected
// Regex matches first space
let right = inputValue.slice(selectionStart).search(/\s/);
if (right >= 0) {
right += selectionStart;
} else if (right < 0) {
right = inputValue.length;
}
// Get the left position for the word selected
// Regex matches last non-whitespace character
let left = inputValue.slice(0, right).search(/\S+$/);
return {
left,
right,
};
if (selectionStart === 0) {
left = 0;
} else if (selectionStart === inputValue.length && left < 0) {
left = inputValue.length;
} else if (left < 0) {
left = selectionStart;
}
return {
left,
right,
};
}
}
window.gl = window.gl || {};
gl.DropdownUtils = DropdownUtils;
})();
window.gl = window.gl || {};
gl.DropdownUtils = DropdownUtils;
(() => {
const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger';
class FilteredSearchDropdown {
constructor(droplab, dropdown, input, filter) {
this.droplab = droplab;
this.hookId = input && input.id;
this.input = input;
this.filter = filter;
this.dropdown = dropdown;
this.loadingTemplate = `<div class="filter-dropdown-loading">
<i class="fa fa-spinner fa-spin"></i>
</div>`;
this.bindEvents();
}
bindEvents() {
this.itemClickedWrapper = this.itemClicked.bind(this);
this.dropdown.addEventListener('click.dl', this.itemClickedWrapper);
}
const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger';
class FilteredSearchDropdown {
constructor(droplab, dropdown, input, filter) {
this.droplab = droplab;
this.hookId = input && input.id;
this.input = input;
this.filter = filter;
this.dropdown = dropdown;
this.loadingTemplate = `<div class="filter-dropdown-loading">
<i class="fa fa-spinner fa-spin"></i>
</div>`;
this.bindEvents();
}
unbindEvents() {
this.dropdown.removeEventListener('click.dl', this.itemClickedWrapper);
}
bindEvents() {
this.itemClickedWrapper = this.itemClicked.bind(this);
this.dropdown.addEventListener('click.dl', this.itemClickedWrapper);
}
getCurrentHook() {
return this.droplab.hooks.filter(h => h.id === this.hookId)[0] || null;
}
unbindEvents() {
this.dropdown.removeEventListener('click.dl', this.itemClickedWrapper);
}
itemClicked(e, getValueFunction) {
const { selected } = e.detail;
getCurrentHook() {
return this.droplab.hooks.filter(h => h.id === this.hookId)[0] || null;
}
if (selected.tagName === 'LI' && selected.innerHTML) {
const dataValueSet = gl.DropdownUtils.setDataValueIfSelected(this.filter, selected);
itemClicked(e, getValueFunction) {
const { selected } = e.detail;
if (!dataValueSet) {
const value = getValueFunction(selected);
gl.FilteredSearchDropdownManager.addWordToInput(this.filter, value, true);
}
if (selected.tagName === 'LI' && selected.innerHTML) {
const dataValueSet = gl.DropdownUtils.setDataValueIfSelected(this.filter, selected);
this.resetFilters();
this.dismissDropdown();
this.dispatchInputEvent();
if (!dataValueSet) {
const value = getValueFunction(selected);
gl.FilteredSearchDropdownManager.addWordToInput(this.filter, value, true);
}
}
setAsDropdown() {
this.input.setAttribute(DATA_DROPDOWN_TRIGGER, `#${this.dropdown.id}`);
this.resetFilters();
this.dismissDropdown();
this.dispatchInputEvent();
}
}
setOffset(offset = 0) {
if (window.innerWidth > 480) {
this.dropdown.style.left = `${offset}px`;
} else {
this.dropdown.style.left = '0px';
}
setAsDropdown() {
this.input.setAttribute(DATA_DROPDOWN_TRIGGER, `#${this.dropdown.id}`);
}
setOffset(offset = 0) {
if (window.innerWidth > 480) {
this.dropdown.style.left = `${offset}px`;
} else {
this.dropdown.style.left = '0px';
}
}
renderContent(forceShowList = false) {
const currentHook = this.getCurrentHook();
if (forceShowList && currentHook && currentHook.list.hidden) {
currentHook.list.show();
}
renderContent(forceShowList = false) {
const currentHook = this.getCurrentHook();
if (forceShowList && currentHook && currentHook.list.hidden) {
currentHook.list.show();
}
}
render(forceRenderContent = false, forceShowList = false) {
this.setAsDropdown();
render(forceRenderContent = false, forceShowList = false) {
this.setAsDropdown();
const currentHook = this.getCurrentHook();
const firstTimeInitialized = currentHook === null;
const currentHook = this.getCurrentHook();
const firstTimeInitialized = currentHook === null;
if (firstTimeInitialized || forceRenderContent) {
this.renderContent(forceShowList);
} else if (currentHook.list.list.id !== this.dropdown.id) {
this.renderContent(forceShowList);
}
if (firstTimeInitialized || forceRenderContent) {
this.renderContent(forceShowList);
} else if (currentHook.list.list.id !== this.dropdown.id) {
this.renderContent(forceShowList);
}
}
dismissDropdown() {
// Focusing on the input will dismiss dropdown
// (default droplab functionality)
this.input.focus();
}
dismissDropdown() {
// Focusing on the input will dismiss dropdown
// (default droplab functionality)
this.input.focus();
}
dispatchInputEvent() {
// Propogate input change to FilteredSearchDropdownManager
// so that it can determine which dropdowns to open
this.input.dispatchEvent(new CustomEvent('input', {
bubbles: true,
cancelable: true,
}));
}
dispatchInputEvent() {
// Propogate input change to FilteredSearchDropdownManager
// so that it can determine which dropdowns to open
this.input.dispatchEvent(new CustomEvent('input', {
bubbles: true,
cancelable: true,
}));
}
dispatchFormSubmitEvent() {
// dispatchEvent() is necessary as form.submit() does not
// trigger event handlers
this.input.form.dispatchEvent(new Event('submit'));
}
dispatchFormSubmitEvent() {
// dispatchEvent() is necessary as form.submit() does not
// trigger event handlers
this.input.form.dispatchEvent(new Event('submit'));
}
hideDropdown() {
const currentHook = this.getCurrentHook();
if (currentHook) {
currentHook.list.hide();
}
hideDropdown() {
const currentHook = this.getCurrentHook();
if (currentHook) {
currentHook.list.hide();
}
}
resetFilters() {
const hook = this.getCurrentHook();
resetFilters() {
const hook = this.getCurrentHook();
if (hook) {
const data = hook.list.data || [];
if (hook) {
const data = hook.list.data || [];
if (!data) return;
if (!data) return;
const results = data.map((o) => {
const updated = o;
updated.droplab_hidden = false;
return updated;
});
hook.list.render(results);
}
const results = data.map((o) => {
const updated = o;
updated.droplab_hidden = false;
return updated;
});
hook.list.render(results);
}
}
}
window.gl = window.gl || {};
gl.FilteredSearchDropdown = FilteredSearchDropdown;
})();
window.gl = window.gl || {};
gl.FilteredSearchDropdown = FilteredSearchDropdown;
import DropLab from '~/droplab/drop_lab';
import FilteredSearchContainer from './container';
(() => {
class FilteredSearchDropdownManager {
constructor(baseEndpoint = '', page) {
this.container = FilteredSearchContainer.container;
this.baseEndpoint = baseEndpoint.replace(/\/$/, '');
this.tokenizer = gl.FilteredSearchTokenizer;
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.page = page;
if (this.page === 'issues' || this.page === 'boards') {
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeysWithWeights;
}
this.setupMapping();
this.cleanupWrapper = this.cleanup.bind(this);
document.addEventListener('beforeunload', this.cleanupWrapper);
class FilteredSearchDropdownManager {
constructor(baseEndpoint = '', page) {
this.container = FilteredSearchContainer.container;
this.baseEndpoint = baseEndpoint.replace(/\/$/, '');
this.tokenizer = gl.FilteredSearchTokenizer;
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.page = page;
if (this.page === 'issues' || this.page === 'boards') {
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeysWithWeights;
}
cleanup() {
if (this.droplab) {
this.droplab.destroy();
this.droplab = null;
}
this.setupMapping();
this.setupMapping();
this.cleanupWrapper = this.cleanup.bind(this);
document.addEventListener('beforeunload', this.cleanupWrapper);
}
document.removeEventListener('beforeunload', this.cleanupWrapper);
cleanup() {
if (this.droplab) {
this.droplab.destroy();
this.droplab = null;
}
setupMapping() {
this.mapping = {
author: {
reference: null,
gl: 'DropdownUser',
element: this.container.querySelector('#js-dropdown-author'),
},
assignee: {
reference: null,
gl: 'DropdownUser',
element: this.container.querySelector('#js-dropdown-assignee'),
},
milestone: {
reference: null,
gl: 'DropdownNonUser',
extraArguments: [`${this.baseEndpoint}/milestones.json`, '%'],
element: this.container.querySelector('#js-dropdown-milestone'),
},
label: {
reference: null,
gl: 'DropdownNonUser',
extraArguments: [`${this.baseEndpoint}/labels.json`, '~'],
element: this.container.querySelector('#js-dropdown-label'),
},
hint: {
reference: null,
gl: 'DropdownHint',
element: this.container.querySelector('#js-dropdown-hint'),
},
};
this.setupMapping();
if (this.page === 'issues' || this.page === 'boards') {
this.mapping.weight = {
reference: null,
gl: 'DropdownNonUser',
element: document.querySelector('#js-dropdown-weight'),
};
}
document.removeEventListener('beforeunload', this.cleanupWrapper);
}
setupMapping() {
this.mapping = {
author: {
reference: null,
gl: 'DropdownUser',
element: this.container.querySelector('#js-dropdown-author'),
},
assignee: {
reference: null,
gl: 'DropdownUser',
element: this.container.querySelector('#js-dropdown-assignee'),
},
milestone: {
reference: null,
gl: 'DropdownNonUser',
extraArguments: [`${this.baseEndpoint}/milestones.json`, '%'],
element: this.container.querySelector('#js-dropdown-milestone'),
},
label: {
reference: null,
gl: 'DropdownNonUser',
extraArguments: [`${this.baseEndpoint}/labels.json`, '~'],
element: this.container.querySelector('#js-dropdown-label'),
},
hint: {
reference: null,
gl: 'DropdownHint',
element: this.container.querySelector('#js-dropdown-hint'),
},
};
if (this.page === 'issues' || this.page === 'boards') {
this.mapping.weight = {
reference: null,
gl: 'DropdownNonUser',
element: document.querySelector('#js-dropdown-weight'),
};
}
}
static addWordToInput(tokenName, tokenValue = '', clicked = false) {
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
static addWordToInput(tokenName, tokenValue = '', clicked = false) {
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue);
input.value = '';
gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue);
input.value = '';
if (clicked) {
gl.FilteredSearchVisualTokens.moveInputToTheRight();
}
if (clicked) {
gl.FilteredSearchVisualTokens.moveInputToTheRight();
}
}
updateCurrentDropdownOffset() {
this.updateDropdownOffset(this.currentDropdown);
}
updateCurrentDropdownOffset() {
this.updateDropdownOffset(this.currentDropdown);
updateDropdownOffset(key) {
// Always align dropdown with the input field
let offset = this.filteredSearchInput.getBoundingClientRect().left - this.container.querySelector('.scroll-container').getBoundingClientRect().left;
const maxInputWidth = 240;
const currentDropdownWidth = this.mapping[key].element.clientWidth || maxInputWidth;
// Make sure offset never exceeds the input container
const offsetMaxWidth = this.container.querySelector('.scroll-container').clientWidth - currentDropdownWidth;
if (offsetMaxWidth < offset) {
offset = offsetMaxWidth;
}
updateDropdownOffset(key) {
// Always align dropdown with the input field
let offset = this.filteredSearchInput.getBoundingClientRect().left - this.container.querySelector('.scroll-container').getBoundingClientRect().left;
this.mapping[key].reference.setOffset(offset);
}
const maxInputWidth = 240;
const currentDropdownWidth = this.mapping[key].element.clientWidth || maxInputWidth;
load(key, firstLoad = false) {
const mappingKey = this.mapping[key];
const glClass = mappingKey.gl;
const element = mappingKey.element;
let forceShowList = false;
// Make sure offset never exceeds the input container
const offsetMaxWidth = this.container.querySelector('.scroll-container').clientWidth - currentDropdownWidth;
if (offsetMaxWidth < offset) {
offset = offsetMaxWidth;
}
if (!mappingKey.reference) {
const dl = this.droplab;
const defaultArguments = [null, dl, element, this.filteredSearchInput, key];
const glArguments = defaultArguments.concat(mappingKey.extraArguments || []);
this.mapping[key].reference.setOffset(offset);
// Passing glArguments to `new gl[glClass](<arguments>)`
mappingKey.reference = new (Function.prototype.bind.apply(gl[glClass], glArguments))();
}
load(key, firstLoad = false) {
const mappingKey = this.mapping[key];
const glClass = mappingKey.gl;
const element = mappingKey.element;
let forceShowList = false;
if (!mappingKey.reference) {
const dl = this.droplab;
const defaultArguments = [null, dl, element, this.filteredSearchInput, key];
const glArguments = defaultArguments.concat(mappingKey.extraArguments || []);
if (firstLoad) {
mappingKey.reference.init();
}
// Passing glArguments to `new gl[glClass](<arguments>)`
mappingKey.reference = new (Function.prototype.bind.apply(gl[glClass], glArguments))();
}
if (this.currentDropdown === 'hint') {
// Force the dropdown to show if it was clicked from the hint dropdown
forceShowList = true;
}
if (firstLoad) {
mappingKey.reference.init();
}
this.updateDropdownOffset(key);
mappingKey.reference.render(firstLoad, forceShowList);
if (this.currentDropdown === 'hint') {
// Force the dropdown to show if it was clicked from the hint dropdown
forceShowList = true;
}
this.currentDropdown = key;
}
this.updateDropdownOffset(key);
mappingKey.reference.render(firstLoad, forceShowList);
loadDropdown(dropdownName = '') {
let firstLoad = false;
this.currentDropdown = key;
if (!this.droplab) {
firstLoad = true;
this.droplab = new DropLab();
}
loadDropdown(dropdownName = '') {
let firstLoad = false;
const match = this.filteredSearchTokenKeys.searchByKey(dropdownName.toLowerCase());
const shouldOpenFilterDropdown = match && this.currentDropdown !== match.key
&& this.mapping[match.key];
const shouldOpenHintDropdown = !match && this.currentDropdown !== 'hint';
if (!this.droplab) {
firstLoad = true;
this.droplab = new DropLab();
}
if (shouldOpenFilterDropdown || shouldOpenHintDropdown) {
const key = match && match.key ? match.key : 'hint';
this.load(key, firstLoad);
}
}
const match = this.filteredSearchTokenKeys.searchByKey(dropdownName.toLowerCase());
const shouldOpenFilterDropdown = match && this.currentDropdown !== match.key
&& this.mapping[match.key];
const shouldOpenHintDropdown = !match && this.currentDropdown !== 'hint';
setDropdown() {
const query = gl.DropdownUtils.getSearchQuery(true);
const { lastToken, searchToken } = this.tokenizer.processTokens(query);
if (shouldOpenFilterDropdown || shouldOpenHintDropdown) {
const key = match && match.key ? match.key : 'hint';
this.load(key, firstLoad);
}
if (this.currentDropdown) {
this.updateCurrentDropdownOffset();
}
setDropdown() {
const query = gl.DropdownUtils.getSearchQuery(true);
const { lastToken, searchToken } = this.tokenizer.processTokens(query);
if (this.currentDropdown) {
this.updateCurrentDropdownOffset();
}
if (lastToken === searchToken && lastToken !== null) {
// Token is not fully initialized yet because it has no value
// Eg. token = 'label:'
const split = lastToken.split(':');
const dropdownName = split[0].split(' ').last();
this.loadDropdown(split.length > 1 ? dropdownName : '');
} else if (lastToken) {
// Token has been initialized into an object because it has a value
this.loadDropdown(lastToken.key);
} else {
this.loadDropdown('hint');
}
if (lastToken === searchToken && lastToken !== null) {
// Token is not fully initialized yet because it has no value
// Eg. token = 'label:'
const split = lastToken.split(':');
const dropdownName = split[0].split(' ').last();
this.loadDropdown(split.length > 1 ? dropdownName : '');
} else if (lastToken) {
// Token has been initialized into an object because it has a value
this.loadDropdown(lastToken.key);
} else {
this.loadDropdown('hint');
}
}
resetDropdowns() {
if (!this.currentDropdown) {
return;
}
resetDropdowns() {
if (!this.currentDropdown) {
return;
}
// Force current dropdown to hide
this.mapping[this.currentDropdown].reference.hideDropdown();
// Force current dropdown to hide
this.mapping[this.currentDropdown].reference.hideDropdown();
// Re-Load dropdown
this.setDropdown();
// Re-Load dropdown
this.setDropdown();
// Reset filters for current dropdown
this.mapping[this.currentDropdown].reference.resetFilters();
// Reset filters for current dropdown
this.mapping[this.currentDropdown].reference.resetFilters();
// Reposition dropdown so that it is aligned with cursor
this.updateDropdownOffset(this.currentDropdown);
}
// Reposition dropdown so that it is aligned with cursor
this.updateDropdownOffset(this.currentDropdown);
}
destroyDroplab() {
this.droplab.destroy();
}
destroyDroplab() {
this.droplab.destroy();
}
}
window.gl = window.gl || {};
gl.FilteredSearchDropdownManager = FilteredSearchDropdownManager;
})();
window.gl = window.gl || {};
gl.FilteredSearchDropdownManager = FilteredSearchDropdownManager;
......@@ -6,501 +6,497 @@ import RecentSearchesStore from './stores/recent_searches_store';
import RecentSearchesService from './services/recent_searches_service';
import eventHub from './event_hub';
(() => {
class FilteredSearchManager {
constructor(page) {
this.container = FilteredSearchContainer.container;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.filteredSearchInputForm = this.filteredSearchInput.form;
this.clearSearchButton = this.container.querySelector('.clear-search');
this.tokensContainer = this.container.querySelector('.tokens-container');
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
if (page === 'issues' || page === 'boards') {
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeysWithWeights;
}
class FilteredSearchManager {
constructor(page) {
this.container = FilteredSearchContainer.container;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.filteredSearchInputForm = this.filteredSearchInput.form;
this.clearSearchButton = this.container.querySelector('.clear-search');
this.tokensContainer = this.container.querySelector('.tokens-container');
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
if (page === 'issues' || page === 'boards') {
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) {
this.tokenizer = gl.FilteredSearchTokenizer;
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.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.recentSearchesRoot.init();
this.recentSearchesService.save(resultantSearches);
});
this.bindEvents();
this.loadSearchParamsFromURL();
this.dropdownManager.setDropdown();
if (this.filteredSearchInput) {
this.tokenizer = gl.FilteredSearchTokenizer;
this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page);
this.cleanupWrapper = this.cleanup.bind(this);
document.addEventListener('beforeunload', this.cleanupWrapper);
}
}
this.recentSearchesRoot = new RecentSearchesRoot(
this.recentSearchesStore,
this.recentSearchesService,
document.querySelector('.js-filtered-search-history-dropdown'),
);
this.recentSearchesRoot.init();
cleanup() {
this.unbindEvents();
document.removeEventListener('beforeunload', this.cleanupWrapper);
this.bindEvents();
this.loadSearchParamsFromURL();
this.dropdownManager.setDropdown();
if (this.recentSearchesRoot) {
this.recentSearchesRoot.destroy();
}
this.cleanupWrapper = this.cleanup.bind(this);
document.addEventListener('beforeunload', this.cleanupWrapper);
}
}
bindEvents() {
this.handleFormSubmit = this.handleFormSubmit.bind(this);
this.setDropdownWrapper = this.dropdownManager.setDropdown.bind(this.dropdownManager);
this.toggleClearSearchButtonWrapper = this.toggleClearSearchButton.bind(this);
this.handleInputPlaceholderWrapper = this.handleInputPlaceholder.bind(this);
this.handleInputVisualTokenWrapper = this.handleInputVisualToken.bind(this);
this.checkForEnterWrapper = this.checkForEnter.bind(this);
this.onClearSearchWrapper = this.onClearSearch.bind(this);
this.checkForBackspaceWrapper = this.checkForBackspace.bind(this);
this.removeSelectedTokenWrapper = this.removeSelectedToken.bind(this);
this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this);
this.editTokenWrapper = this.editToken.bind(this);
this.tokenChange = this.tokenChange.bind(this);
this.addInputContainerFocusWrapper = this.addInputContainerFocus.bind(this);
this.removeInputContainerFocusWrapper = this.removeInputContainerFocus.bind(this);
this.onrecentSearchesItemSelectedWrapper = this.onrecentSearchesItemSelected.bind(this);
this.filteredSearchInputForm.addEventListener('submit', this.handleFormSubmit);
this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper);
this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper);
this.filteredSearchInput.addEventListener('input', this.handleInputPlaceholderWrapper);
this.filteredSearchInput.addEventListener('input', this.handleInputVisualTokenWrapper);
this.filteredSearchInput.addEventListener('keydown', this.checkForEnterWrapper);
this.filteredSearchInput.addEventListener('keyup', this.checkForBackspaceWrapper);
this.filteredSearchInput.addEventListener('click', this.tokenChange);
this.filteredSearchInput.addEventListener('keyup', this.tokenChange);
this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper);
this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper);
this.clearSearchButton.addEventListener('click', this.onClearSearchWrapper);
document.addEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
document.addEventListener('click', this.unselectEditTokensWrapper);
document.addEventListener('click', this.removeInputContainerFocusWrapper);
document.addEventListener('keydown', this.removeSelectedTokenWrapper);
eventHub.$on('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
}
cleanup() {
this.unbindEvents();
document.removeEventListener('beforeunload', this.cleanupWrapper);
unbindEvents() {
this.filteredSearchInputForm.removeEventListener('submit', this.handleFormSubmit);
this.filteredSearchInput.removeEventListener('input', this.setDropdownWrapper);
this.filteredSearchInput.removeEventListener('input', this.toggleClearSearchButtonWrapper);
this.filteredSearchInput.removeEventListener('input', this.handleInputPlaceholderWrapper);
this.filteredSearchInput.removeEventListener('input', this.handleInputVisualTokenWrapper);
this.filteredSearchInput.removeEventListener('keydown', this.checkForEnterWrapper);
this.filteredSearchInput.removeEventListener('keyup', this.checkForBackspaceWrapper);
this.filteredSearchInput.removeEventListener('click', this.tokenChange);
this.filteredSearchInput.removeEventListener('keyup', this.tokenChange);
this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper);
this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper);
this.clearSearchButton.removeEventListener('click', this.onClearSearchWrapper);
document.removeEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
document.removeEventListener('click', this.unselectEditTokensWrapper);
document.removeEventListener('click', this.removeInputContainerFocusWrapper);
document.removeEventListener('keydown', this.removeSelectedTokenWrapper);
eventHub.$off('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
if (this.recentSearchesRoot) {
this.recentSearchesRoot.destroy();
}
}
checkForBackspace(e) {
// 8 = Backspace Key
// 46 = Delete Key
if (e.keyCode === 8 || e.keyCode === 46) {
const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
bindEvents() {
this.handleFormSubmit = this.handleFormSubmit.bind(this);
this.setDropdownWrapper = this.dropdownManager.setDropdown.bind(this.dropdownManager);
this.toggleClearSearchButtonWrapper = this.toggleClearSearchButton.bind(this);
this.handleInputPlaceholderWrapper = this.handleInputPlaceholder.bind(this);
this.handleInputVisualTokenWrapper = this.handleInputVisualToken.bind(this);
this.checkForEnterWrapper = this.checkForEnter.bind(this);
this.onClearSearchWrapper = this.onClearSearch.bind(this);
this.checkForBackspaceWrapper = this.checkForBackspace.bind(this);
this.removeSelectedTokenWrapper = this.removeSelectedToken.bind(this);
this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this);
this.editTokenWrapper = this.editToken.bind(this);
this.tokenChange = this.tokenChange.bind(this);
this.addInputContainerFocusWrapper = this.addInputContainerFocus.bind(this);
this.removeInputContainerFocusWrapper = this.removeInputContainerFocus.bind(this);
this.onrecentSearchesItemSelectedWrapper = this.onrecentSearchesItemSelected.bind(this);
this.filteredSearchInputForm.addEventListener('submit', this.handleFormSubmit);
this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper);
this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper);
this.filteredSearchInput.addEventListener('input', this.handleInputPlaceholderWrapper);
this.filteredSearchInput.addEventListener('input', this.handleInputVisualTokenWrapper);
this.filteredSearchInput.addEventListener('keydown', this.checkForEnterWrapper);
this.filteredSearchInput.addEventListener('keyup', this.checkForBackspaceWrapper);
this.filteredSearchInput.addEventListener('click', this.tokenChange);
this.filteredSearchInput.addEventListener('keyup', this.tokenChange);
this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper);
this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper);
this.clearSearchButton.addEventListener('click', this.onClearSearchWrapper);
document.addEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
document.addEventListener('click', this.unselectEditTokensWrapper);
document.addEventListener('click', this.removeInputContainerFocusWrapper);
document.addEventListener('keydown', this.removeSelectedTokenWrapper);
eventHub.$on('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
}
if (this.filteredSearchInput.value === '' && lastVisualToken) {
if (this.canEdit && !this.canEdit(lastVisualToken)) return;
unbindEvents() {
this.filteredSearchInputForm.removeEventListener('submit', this.handleFormSubmit);
this.filteredSearchInput.removeEventListener('input', this.setDropdownWrapper);
this.filteredSearchInput.removeEventListener('input', this.toggleClearSearchButtonWrapper);
this.filteredSearchInput.removeEventListener('input', this.handleInputPlaceholderWrapper);
this.filteredSearchInput.removeEventListener('input', this.handleInputVisualTokenWrapper);
this.filteredSearchInput.removeEventListener('keydown', this.checkForEnterWrapper);
this.filteredSearchInput.removeEventListener('keyup', this.checkForBackspaceWrapper);
this.filteredSearchInput.removeEventListener('click', this.tokenChange);
this.filteredSearchInput.removeEventListener('keyup', this.tokenChange);
this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper);
this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper);
this.clearSearchButton.removeEventListener('click', this.onClearSearchWrapper);
document.removeEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
document.removeEventListener('click', this.unselectEditTokensWrapper);
document.removeEventListener('click', this.removeInputContainerFocusWrapper);
document.removeEventListener('keydown', this.removeSelectedTokenWrapper);
eventHub.$off('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
}
this.filteredSearchInput.value = gl.FilteredSearchVisualTokens.getLastTokenPartial();
gl.FilteredSearchVisualTokens.removeLastTokenPartial();
}
checkForBackspace(e) {
// 8 = Backspace Key
// 46 = Delete Key
if (e.keyCode === 8 || e.keyCode === 46) {
const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (this.filteredSearchInput.value === '' && lastVisualToken) {
if (this.canEdit && !this.canEdit(lastVisualToken)) return;
// Reposition dropdown so that it is aligned with cursor
this.dropdownManager.updateCurrentDropdownOffset();
this.filteredSearchInput.value = gl.FilteredSearchVisualTokens.getLastTokenPartial();
gl.FilteredSearchVisualTokens.removeLastTokenPartial();
}
}
checkForEnter(e) {
if (e.keyCode === 38 || e.keyCode === 40) {
const selectionStart = this.filteredSearchInput.selectionStart;
// Reposition dropdown so that it is aligned with cursor
this.dropdownManager.updateCurrentDropdownOffset();
}
}
e.preventDefault();
this.filteredSearchInput.setSelectionRange(selectionStart, selectionStart);
}
checkForEnter(e) {
if (e.keyCode === 38 || e.keyCode === 40) {
const selectionStart = this.filteredSearchInput.selectionStart;
if (e.keyCode === 13) {
const dropdown = this.dropdownManager.mapping[this.dropdownManager.currentDropdown];
const dropdownEl = dropdown.element;
const activeElements = dropdownEl.querySelectorAll('.droplab-item-active');
e.preventDefault();
this.filteredSearchInput.setSelectionRange(selectionStart, selectionStart);
}
e.preventDefault();
if (e.keyCode === 13) {
const dropdown = this.dropdownManager.mapping[this.dropdownManager.currentDropdown];
const dropdownEl = dropdown.element;
const activeElements = dropdownEl.querySelectorAll('.droplab-item-active');
if (!activeElements.length) {
if (this.isHandledAsync) {
e.stopImmediatePropagation();
e.preventDefault();
this.filteredSearchInput.blur();
this.dropdownManager.resetDropdowns();
} else {
// Prevent droplab from opening dropdown
this.dropdownManager.destroyDroplab();
}
if (!activeElements.length) {
if (this.isHandledAsync) {
e.stopImmediatePropagation();
this.search();
this.filteredSearchInput.blur();
this.dropdownManager.resetDropdowns();
} else {
// Prevent droplab from opening dropdown
this.dropdownManager.destroyDroplab();
}
this.search();
}
}
}
addInputContainerFocus() {
const inputContainer = this.filteredSearchInput.closest('.filtered-search-box');
addInputContainerFocus() {
const inputContainer = this.filteredSearchInput.closest('.filtered-search-box');
if (inputContainer) {
inputContainer.classList.add('focus');
}
if (inputContainer) {
inputContainer.classList.add('focus');
}
}
removeInputContainerFocus(e) {
const inputContainer = this.filteredSearchInput.closest('.filtered-search-box');
const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
const isElementInDynamicFilterDropdown = e.target.closest('.filter-dropdown') !== null;
const isElementInStaticFilterDropdown = e.target.closest('ul[data-dropdown]') !== null;
removeInputContainerFocus(e) {
const inputContainer = this.filteredSearchInput.closest('.filtered-search-box');
const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
const isElementInDynamicFilterDropdown = e.target.closest('.filter-dropdown') !== null;
const isElementInStaticFilterDropdown = e.target.closest('ul[data-dropdown]') !== null;
if (!isElementInFilteredSearch && !isElementInDynamicFilterDropdown &&
!isElementInStaticFilterDropdown && inputContainer) {
inputContainer.classList.remove('focus');
}
if (!isElementInFilteredSearch && !isElementInDynamicFilterDropdown &&
!isElementInStaticFilterDropdown && inputContainer) {
inputContainer.classList.remove('focus');
}
}
static selectToken(e) {
const button = e.target.closest('.selectable');
static selectToken(e) {
const button = e.target.closest('.selectable');
if (button) {
e.preventDefault();
e.stopPropagation();
gl.FilteredSearchVisualTokens.selectToken(button);
}
if (button) {
e.preventDefault();
e.stopPropagation();
gl.FilteredSearchVisualTokens.selectToken(button);
}
}
unselectEditTokens(e) {
const inputContainer = this.container.querySelector('.filtered-search-box');
const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
const isElementInFilterDropdown = e.target.closest('.filter-dropdown') !== null;
const isElementTokensContainer = e.target.classList.contains('tokens-container');
unselectEditTokens(e) {
const inputContainer = this.container.querySelector('.filtered-search-box');
const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
const isElementInFilterDropdown = e.target.closest('.filter-dropdown') !== null;
const isElementTokensContainer = e.target.classList.contains('tokens-container');
if ((!isElementInFilteredSearch && !isElementInFilterDropdown) || isElementTokensContainer) {
gl.FilteredSearchVisualTokens.moveInputToTheRight();
this.dropdownManager.resetDropdowns();
}
if ((!isElementInFilteredSearch && !isElementInFilterDropdown) || isElementTokensContainer) {
gl.FilteredSearchVisualTokens.moveInputToTheRight();
this.dropdownManager.resetDropdowns();
}
}
editToken(e) {
const token = e.target.closest('.js-visual-token');
editToken(e) {
const token = e.target.closest('.js-visual-token');
if (this.canEdit && !this.canEdit(token)) return;
if (this.canEdit && !this.canEdit(token)) return;
if (token) {
gl.FilteredSearchVisualTokens.editToken(token);
this.tokenChange();
}
if (token) {
gl.FilteredSearchVisualTokens.editToken(token);
this.tokenChange();
}
}
toggleClearSearchButton() {
const query = gl.DropdownUtils.getSearchQuery();
const hidden = 'hidden';
const hasHidden = this.clearSearchButton.classList.contains(hidden);
toggleClearSearchButton() {
const query = gl.DropdownUtils.getSearchQuery();
const hidden = 'hidden';
const hasHidden = this.clearSearchButton.classList.contains(hidden);
if (query.length === 0 && !hasHidden) {
this.clearSearchButton.classList.add(hidden);
} else if (query.length && hasHidden) {
this.clearSearchButton.classList.remove(hidden);
}
if (query.length === 0 && !hasHidden) {
this.clearSearchButton.classList.add(hidden);
} else if (query.length && hasHidden) {
this.clearSearchButton.classList.remove(hidden);
}
}
handleInputPlaceholder() {
const query = gl.DropdownUtils.getSearchQuery();
const placeholder = 'Search or filter results...';
const currentPlaceholder = this.filteredSearchInput.placeholder;
if (query.length === 0 && currentPlaceholder !== placeholder) {
this.filteredSearchInput.placeholder = placeholder;
} else if (query.length > 0 && currentPlaceholder !== '') {
this.filteredSearchInput.placeholder = '';
}
}
handleInputPlaceholder() {
const query = gl.DropdownUtils.getSearchQuery();
const placeholder = 'Search or filter results...';
const currentPlaceholder = this.filteredSearchInput.placeholder;
removeSelectedToken(e) {
// 8 = Backspace Key
// 46 = Delete Key
if (e.keyCode === 8 || e.keyCode === 46) {
gl.FilteredSearchVisualTokens.removeSelectedToken();
this.handleInputPlaceholder();
this.toggleClearSearchButton();
}
if (query.length === 0 && currentPlaceholder !== placeholder) {
this.filteredSearchInput.placeholder = placeholder;
} else if (query.length > 0 && currentPlaceholder !== '') {
this.filteredSearchInput.placeholder = '';
}
}
onClearSearch(e) {
e.preventDefault();
this.clearSearch();
removeSelectedToken(e) {
// 8 = Backspace Key
// 46 = Delete Key
if (e.keyCode === 8 || e.keyCode === 46) {
gl.FilteredSearchVisualTokens.removeSelectedToken();
this.handleInputPlaceholder();
this.toggleClearSearchButton();
}
}
clearSearch() {
this.filteredSearchInput.value = '';
onClearSearch(e) {
e.preventDefault();
this.clearSearch();
}
const removeElements = [];
clearSearch() {
this.filteredSearchInput.value = '';
[].forEach.call(this.tokensContainer.children, (t) => {
if (t.classList.contains('js-visual-token')) {
if (this.canEdit && !this.canEdit(t)) return;
const removeElements = [];
removeElements.push(t);
}
});
[].forEach.call(this.tokensContainer.children, (t) => {
if (t.classList.contains('js-visual-token')) {
removeElements.push(t);
}
});
removeElements.forEach((el) => {
el.parentElement.removeChild(el);
});
removeElements.forEach((el) => {
el.parentElement.removeChild(el);
});
this.clearSearchButton.classList.add('hidden');
this.handleInputPlaceholder();
this.clearSearchButton.classList.add('hidden');
this.handleInputPlaceholder();
this.dropdownManager.resetDropdowns();
this.dropdownManager.resetDropdowns();
if (this.isHandledAsync) {
this.search();
}
if (this.isHandledAsync) {
this.search();
}
}
handleInputVisualToken() {
const input = this.filteredSearchInput;
const { tokens, searchToken }
= gl.FilteredSearchTokenizer.processTokens(input.value);
const { isLastVisualTokenValid }
= gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (isLastVisualTokenValid) {
tokens.forEach((t) => {
input.value = input.value.replace(`${t.key}:${t.symbol}${t.value}`, '');
gl.FilteredSearchVisualTokens.addFilterVisualToken(t.key, `${t.symbol}${t.value}`);
});
const fragments = searchToken.split(':');
if (fragments.length > 1) {
const inputValues = fragments[0].split(' ');
const tokenKey = inputValues.last();
if (inputValues.length > 1) {
inputValues.pop();
const searchTerms = inputValues.join(' ');
input.value = input.value.replace(searchTerms, '');
gl.FilteredSearchVisualTokens.addSearchVisualToken(searchTerms);
}
handleInputVisualToken() {
const input = this.filteredSearchInput;
const { tokens, searchToken }
= gl.FilteredSearchTokenizer.processTokens(input.value);
const { isLastVisualTokenValid }
= gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (isLastVisualTokenValid) {
tokens.forEach((t) => {
input.value = input.value.replace(`${t.key}:${t.symbol}${t.value}`, '');
gl.FilteredSearchVisualTokens.addFilterVisualToken(t.key, `${t.symbol}${t.value}`);
});
gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenKey);
input.value = input.value.replace(`${tokenKey}:`, '');
}
} else {
// Keep listening to token until we determine that the user is done typing the token value
const valueCompletedRegex = /([~%@]{0,1}".+")|([~%@]{0,1}'.+')|^((?![~%@]')(?![~%@]")(?!')(?!")).*/g;
const fragments = searchToken.split(':');
if (fragments.length > 1) {
const inputValues = fragments[0].split(' ');
const tokenKey = inputValues.last();
if (searchToken.match(valueCompletedRegex) && input.value[input.value.length - 1] === ' ') {
gl.FilteredSearchVisualTokens.addFilterVisualToken(searchToken);
if (inputValues.length > 1) {
inputValues.pop();
const searchTerms = inputValues.join(' ');
// Trim the last space as seen in the if statement above
input.value = input.value.replace(searchToken, '').trim();
input.value = input.value.replace(searchTerms, '');
gl.FilteredSearchVisualTokens.addSearchVisualToken(searchTerms);
}
gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenKey);
input.value = input.value.replace(`${tokenKey}:`, '');
}
}
} else {
// Keep listening to token until we determine that the user is done typing the token value
const valueCompletedRegex = /([~%@]{0,1}".+")|([~%@]{0,1}'.+')|^((?![~%@]')(?![~%@]")(?!')(?!")).*/g;
handleFormSubmit(e) {
e.preventDefault();
this.search();
}
if (searchToken.match(valueCompletedRegex) && input.value[input.value.length - 1] === ' ') {
gl.FilteredSearchVisualTokens.addFilterVisualToken(searchToken);
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);
}
}).catch(() => {
// https://gitlab.com/gitlab-org/gitlab-ce/issues/30821
});
// Trim the last space as seen in the if statement above
input.value = input.value.replace(searchToken, '').trim();
}
}
}
handleFormSubmit(e) {
e.preventDefault();
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);
}
}).catch(() => {
// https://gitlab.com/gitlab-org/gitlab-ce/issues/30821
});
}
loadSearchParamsFromURL() {
const params = gl.utils.getUrlParamsArray();
const usernameParams = this.getUsernameParams();
let hasFilteredSearch = false;
loadSearchParamsFromURL() {
const params = gl.utils.getUrlParamsArray();
const usernameParams = this.getUsernameParams();
let hasFilteredSearch = false;
params.forEach((p) => {
const split = p.split('=');
const keyParam = decodeURIComponent(split[0]);
const value = split[1];
params.forEach((p) => {
const split = p.split('=');
const keyParam = decodeURIComponent(split[0]);
const value = split[1];
// Check if it matches edge conditions listed in this.filteredSearchTokenKeys
const condition = this.filteredSearchTokenKeys.searchByConditionUrl(p);
// Check if it matches edge conditions listed in this.filteredSearchTokenKeys
const condition = this.filteredSearchTokenKeys.searchByConditionUrl(p);
if (condition) {
hasFilteredSearch = true;
gl.FilteredSearchVisualTokens.addFilterVisualToken(condition.tokenKey, condition.value);
} else {
// Sanitize value since URL converts spaces into +
// Replace before decode so that we know what was originally + versus the encoded +
const sanitizedValue = value ? decodeURIComponent(value.replace(/\+/g, ' ')) : value;
const match = this.filteredSearchTokenKeys.searchByKeyParam(keyParam);
if (match) {
const indexOf = keyParam.indexOf('_');
const sanitizedKey = indexOf !== -1 ? keyParam.slice(0, keyParam.indexOf('_')) : keyParam;
const symbol = match.symbol;
let quotationsToUse = '';
if (sanitizedValue.indexOf(' ') !== -1) {
// Prefer ", but use ' if required
quotationsToUse = sanitizedValue.indexOf('"') === -1 ? '"' : '\'';
}
if (condition) {
hasFilteredSearch = true;
gl.FilteredSearchVisualTokens.addFilterVisualToken(condition.tokenKey, condition.value);
} else {
// Sanitize value since URL converts spaces into +
// Replace before decode so that we know what was originally + versus the encoded +
const sanitizedValue = value ? decodeURIComponent(value.replace(/\+/g, ' ')) : value;
const match = this.filteredSearchTokenKeys.searchByKeyParam(keyParam);
if (match) {
const indexOf = keyParam.indexOf('_');
const sanitizedKey = indexOf !== -1 ? keyParam.slice(0, keyParam.indexOf('_')) : keyParam;
const symbol = match.symbol;
let quotationsToUse = '';
if (sanitizedValue.indexOf(' ') !== -1) {
// Prefer ", but use ' if required
quotationsToUse = sanitizedValue.indexOf('"') === -1 ? '"' : '\'';
}
hasFilteredSearch = true;
gl.FilteredSearchVisualTokens.addFilterVisualToken(sanitizedKey, `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`);
} else if (!match && keyParam === 'assignee_id') {
const id = parseInt(value, 10);
if (usernameParams[id]) {
hasFilteredSearch = true;
gl.FilteredSearchVisualTokens.addFilterVisualToken(sanitizedKey, `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`);
} else if (!match && keyParam === 'assignee_id') {
const id = parseInt(value, 10);
if (usernameParams[id]) {
hasFilteredSearch = true;
gl.FilteredSearchVisualTokens.addFilterVisualToken('assignee', `@${usernameParams[id]}`);
}
} else if (!match && keyParam === 'author_id') {
const id = parseInt(value, 10);
if (usernameParams[id]) {
hasFilteredSearch = true;
gl.FilteredSearchVisualTokens.addFilterVisualToken('author', `@${usernameParams[id]}`);
}
} else if (!match && keyParam === 'search') {
gl.FilteredSearchVisualTokens.addFilterVisualToken('assignee', `@${usernameParams[id]}`);
}
} else if (!match && keyParam === 'author_id') {
const id = parseInt(value, 10);
if (usernameParams[id]) {
hasFilteredSearch = true;
this.filteredSearchInput.value = sanitizedValue;
gl.FilteredSearchVisualTokens.addFilterVisualToken('author', `@${usernameParams[id]}`);
}
} else if (!match && keyParam === 'search') {
hasFilteredSearch = true;
this.filteredSearchInput.value = sanitizedValue;
}
});
}
});
this.saveCurrentSearchQuery();
this.saveCurrentSearchQuery();
if (hasFilteredSearch) {
this.clearSearchButton.classList.remove('hidden');
this.handleInputPlaceholder();
}
if (hasFilteredSearch) {
this.clearSearchButton.classList.remove('hidden');
this.handleInputPlaceholder();
}
}
search() {
const paths = [];
const searchQuery = gl.DropdownUtils.getSearchQuery();
this.saveCurrentSearchQuery();
search() {
const paths = [];
const searchQuery = gl.DropdownUtils.getSearchQuery();
const { tokens, searchToken }
= this.tokenizer.processTokens(searchQuery);
const currentState = gl.utils.getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`);
this.saveCurrentSearchQuery();
tokens.forEach((token) => {
const condition = this.filteredSearchTokenKeys
.searchByConditionKeyValue(token.key, token.value.toLowerCase());
const { param } = this.filteredSearchTokenKeys.searchByKey(token.key) || {};
const keyParam = param ? `${token.key}_${param}` : token.key;
let tokenPath = '';
const { tokens, searchToken }
= this.tokenizer.processTokens(searchQuery);
const currentState = gl.utils.getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`);
if (condition) {
tokenPath = condition.url;
} else {
let tokenValue = token.value;
tokens.forEach((token) => {
const condition = this.filteredSearchTokenKeys
.searchByConditionKeyValue(token.key, token.value.toLowerCase());
const { param } = this.filteredSearchTokenKeys.searchByKey(token.key) || {};
const keyParam = param ? `${token.key}_${param}` : token.key;
let tokenPath = '';
if ((tokenValue[0] === '\'' && tokenValue[tokenValue.length - 1] === '\'') ||
(tokenValue[0] === '"' && tokenValue[tokenValue.length - 1] === '"')) {
tokenValue = tokenValue.slice(1, tokenValue.length - 1);
}
if (condition) {
tokenPath = condition.url;
} else {
let tokenValue = token.value;
tokenPath = `${keyParam}=${encodeURIComponent(tokenValue)}`;
if ((tokenValue[0] === '\'' && tokenValue[tokenValue.length - 1] === '\'') ||
(tokenValue[0] === '"' && tokenValue[tokenValue.length - 1] === '"')) {
tokenValue = tokenValue.slice(1, tokenValue.length - 1);
}
paths.push(tokenPath);
});
if (searchToken) {
const sanitized = searchToken.split(' ').map(t => encodeURIComponent(t)).join('+');
paths.push(`search=${sanitized}`);
tokenPath = `${keyParam}=${encodeURIComponent(tokenValue)}`;
}
const parameterizedUrl = `?scope=all&utf8=%E2%9C%93&${paths.join('&')}`;
paths.push(tokenPath);
});
if (this.updateObject) {
this.updateObject(parameterizedUrl);
} else {
gl.utils.visitUrl(parameterizedUrl);
}
if (searchToken) {
const sanitized = searchToken.split(' ').map(t => encodeURIComponent(t)).join('+');
paths.push(`search=${sanitized}`);
}
getUsernameParams() {
const usernamesById = {};
try {
const attribute = this.filteredSearchInput.getAttribute('data-username-params');
JSON.parse(attribute).forEach((user) => {
usernamesById[user.id] = user.username;
});
} catch (e) {
// do nothing
}
return usernamesById;
const parameterizedUrl = `?scope=all&utf8=%E2%9C%93&${paths.join('&')}`;
if (this.updateObject) {
this.updateObject(parameterizedUrl);
} else {
gl.utils.visitUrl(parameterizedUrl);
}
}
tokenChange() {
const dropdown = this.dropdownManager.mapping[this.dropdownManager.currentDropdown];
getUsernameParams() {
const usernamesById = {};
try {
const attribute = this.filteredSearchInput.getAttribute('data-username-params');
JSON.parse(attribute).forEach((user) => {
usernamesById[user.id] = user.username;
});
} catch (e) {
// do nothing
}
return usernamesById;
}
if (dropdown) {
const currentDropdownRef = dropdown.reference;
tokenChange() {
const dropdown = this.dropdownManager.mapping[this.dropdownManager.currentDropdown];
this.setDropdownWrapper();
currentDropdownRef.dispatchInputEvent();
}
}
if (dropdown) {
const currentDropdownRef = dropdown.reference;
onrecentSearchesItemSelected(text) {
this.clearSearch();
this.filteredSearchInput.value = text;
this.filteredSearchInput.dispatchEvent(new CustomEvent('input'));
this.search();
this.setDropdownWrapper();
currentDropdownRef.dispatchInputEvent();
}
}
window.gl = window.gl || {};
gl.FilteredSearchManager = FilteredSearchManager;
})();
onrecentSearchesItemSelected(text) {
this.clearSearch();
this.filteredSearchInput.value = text;
this.filteredSearchInput.dispatchEvent(new CustomEvent('input'));
this.search();
}
}
window.gl = window.gl || {};
gl.FilteredSearchManager = FilteredSearchManager;
(() => {
const tokenKeys = [{
key: 'author',
type: 'string',
param: 'username',
symbol: '@',
}, {
key: 'assignee',
type: 'string',
param: 'username',
symbol: '@',
}, {
key: 'milestone',
type: 'string',
param: 'title',
symbol: '%',
}, {
key: 'label',
type: 'array',
param: 'name[]',
symbol: '~',
}];
const tokenKeys = [{
key: 'author',
type: 'string',
param: 'username',
symbol: '@',
}, {
key: 'assignee',
type: 'string',
param: 'username',
symbol: '@',
}, {
key: 'milestone',
type: 'string',
param: 'title',
symbol: '%',
}, {
key: 'label',
type: 'array',
param: 'name[]',
symbol: '~',
}];
const alternativeTokenKeys = [{
key: 'label',
type: 'string',
param: 'name',
symbol: '~',
}];
const alternativeTokenKeys = [{
key: 'label',
type: 'string',
param: 'name',
symbol: '~',
}];
const tokenKeysWithAlternative = tokenKeys.concat(alternativeTokenKeys);
const tokenKeysWithAlternative = tokenKeys.concat(alternativeTokenKeys);
const conditions = [{
url: 'assignee_id=0',
tokenKey: 'assignee',
value: 'none',
}, {
url: 'milestone_title=No+Milestone',
tokenKey: 'milestone',
value: 'none',
}, {
url: 'milestone_title=%23upcoming',
tokenKey: 'milestone',
value: 'upcoming',
}, {
url: 'milestone_title=%23started',
tokenKey: 'milestone',
value: 'started',
}, {
url: 'label_name[]=No+Label',
tokenKey: 'label',
value: 'none',
}];
const conditions = [{
url: 'assignee_id=0',
tokenKey: 'assignee',
value: 'none',
}, {
url: 'milestone_title=No+Milestone',
tokenKey: 'milestone',
value: 'none',
}, {
url: 'milestone_title=%23upcoming',
tokenKey: 'milestone',
value: 'upcoming',
}, {
url: 'milestone_title=%23started',
tokenKey: 'milestone',
value: 'started',
}, {
url: 'label_name[]=No+Label',
tokenKey: 'label',
value: 'none',
}];
class FilteredSearchTokenKeys {
static get() {
return tokenKeys;
}
class FilteredSearchTokenKeys {
static get() {
return tokenKeys;
}
static getAlternatives() {
return alternativeTokenKeys;
}
static getAlternatives() {
return alternativeTokenKeys;
}
static getConditions() {
return conditions;
}
static getConditions() {
return conditions;
}
static searchByKey(key) {
return tokenKeys.find(tokenKey => tokenKey.key === key) || null;
}
static searchByKey(key) {
return tokenKeys.find(tokenKey => tokenKey.key === key) || null;
}
static searchBySymbol(symbol) {
return tokenKeys.find(tokenKey => tokenKey.symbol === symbol) || null;
}
static searchBySymbol(symbol) {
return tokenKeys.find(tokenKey => tokenKey.symbol === symbol) || null;
}
static searchByKeyParam(keyParam) {
return tokenKeysWithAlternative.find((tokenKey) => {
let tokenKeyParam = tokenKey.key;
static searchByKeyParam(keyParam) {
return tokenKeysWithAlternative.find((tokenKey) => {
let tokenKeyParam = tokenKey.key;
if (tokenKey.param) {
tokenKeyParam += `_${tokenKey.param}`;
}
if (tokenKey.param) {
tokenKeyParam += `_${tokenKey.param}`;
}
return keyParam === tokenKeyParam;
}) || null;
}
return keyParam === tokenKeyParam;
}) || null;
}
static searchByConditionUrl(url) {
return conditions.find(condition => condition.url === url) || null;
}
static searchByConditionUrl(url) {
return conditions.find(condition => condition.url === url) || null;
}
static searchByConditionKeyValue(key, value) {
return conditions
.find(condition => condition.tokenKey === key && condition.value === value) || null;
}
static searchByConditionKeyValue(key, value) {
return conditions
.find(condition => condition.tokenKey === key && condition.value === value) || null;
}
}
window.gl = window.gl || {};
gl.FilteredSearchTokenKeys = FilteredSearchTokenKeys;
})();
window.gl = window.gl || {};
gl.FilteredSearchTokenKeys = FilteredSearchTokenKeys;
require('./filtered_search_token_keys');
(() => {
class FilteredSearchTokenizer {
static processTokens(input) {
const allowedKeys = gl.FilteredSearchTokenKeys.get().map(i => i.key);
// Regex extracts `(token):(symbol)(value)`
// Values that start with a double quote must end in a double quote (same for single)
const tokenRegex = new RegExp(`(${allowedKeys.join('|')}):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, 'g');
const tokens = [];
const tokenIndexes = []; // stores key+value for simple search
let lastToken = null;
const searchToken = input.replace(tokenRegex, (match, key, symbol, v1, v2, v3) => {
let tokenValue = v1 || v2 || v3;
let tokenSymbol = symbol;
let tokenIndex = '';
if (tokenValue === '~' || tokenValue === '%' || tokenValue === '@') {
tokenSymbol = tokenValue;
tokenValue = '';
}
tokenIndex = `${key}:${tokenValue}`;
// Prevent adding duplicates
if (tokenIndexes.indexOf(tokenIndex) === -1) {
tokenIndexes.push(tokenIndex);
tokens.push({
key,
value: tokenValue || '',
symbol: tokenSymbol || '',
});
}
return '';
}).replace(/\s{2,}/g, ' ').trim() || '';
if (tokens.length > 0) {
const last = tokens[tokens.length - 1];
const lastString = `${last.key}:${last.symbol}${last.value}`;
lastToken = input.lastIndexOf(lastString) ===
input.length - lastString.length ? last : searchToken;
} else {
lastToken = searchToken;
class FilteredSearchTokenizer {
static processTokens(input) {
const allowedKeys = gl.FilteredSearchTokenKeys.get().map(i => i.key);
// Regex extracts `(token):(symbol)(value)`
// Values that start with a double quote must end in a double quote (same for single)
const tokenRegex = new RegExp(`(${allowedKeys.join('|')}):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, 'g');
const tokens = [];
const tokenIndexes = []; // stores key+value for simple search
let lastToken = null;
const searchToken = input.replace(tokenRegex, (match, key, symbol, v1, v2, v3) => {
let tokenValue = v1 || v2 || v3;
let tokenSymbol = symbol;
let tokenIndex = '';
if (tokenValue === '~' || tokenValue === '%' || tokenValue === '@') {
tokenSymbol = tokenValue;
tokenValue = '';
}
return {
tokens,
lastToken,
searchToken,
};
tokenIndex = `${key}:${tokenValue}`;
// Prevent adding duplicates
if (tokenIndexes.indexOf(tokenIndex) === -1) {
tokenIndexes.push(tokenIndex);
tokens.push({
key,
value: tokenValue || '',
symbol: tokenSymbol || '',
});
}
return '';
}).replace(/\s{2,}/g, ' ').trim() || '';
if (tokens.length > 0) {
const last = tokens[tokens.length - 1];
const lastString = `${last.key}:${last.symbol}${last.value}`;
lastToken = input.lastIndexOf(lastString) ===
input.length - lastString.length ? last : searchToken;
} else {
lastToken = searchToken;
}
return {
tokens,
lastToken,
searchToken,
};
}
}
window.gl = window.gl || {};
gl.FilteredSearchTokenizer = FilteredSearchTokenizer;
})();
window.gl = window.gl || {};
gl.FilteredSearchTokenizer = FilteredSearchTokenizer;
......@@ -368,9 +368,9 @@
});
};
w.gl.utils.setFavicon = (iconName) => {
if (faviconEl && iconName) {
faviconEl.setAttribute('href', `/assets/${iconName}.ico`);
w.gl.utils.setFavicon = (faviconPath) => {
if (faviconEl && faviconPath) {
faviconEl.setAttribute('href', faviconPath);
}
};
......@@ -385,8 +385,8 @@
url: pageUrl,
dataType: 'json',
success: function(data) {
if (data && data.icon) {
gl.utils.setFavicon(`ci_favicons/${data.icon}`);
if (data && data.favicon) {
gl.utils.setFavicon(data.favicon);
} else {
gl.utils.resetFavicon();
}
......
......@@ -165,6 +165,7 @@ import './syntax_highlight';
import './task_list';
import './todos';
import './tree';
import './usage_ping';
import './user';
import './user_tabs';
import './username_validator';
......@@ -218,6 +219,14 @@ $(function () {
}
});
if (bootstrapBreakpoint === 'xs') {
const $rightSidebar = $('aside.right-sidebar, .page-with-sidebar');
$rightSidebar
.removeClass('right-sidebar-expanded')
.addClass('right-sidebar-collapsed');
}
// prevent default action for disabled buttons
$('.btn').click(function(e) {
if ($(this).hasClass('disabled')) {
......
......@@ -308,8 +308,10 @@ require('./task_list');
if (this.isNewNote(note)) {
this.note_ids.push(note.id);
$notesList = $('ul.main-notes-list');
$notesList.append(note.html).syntaxHighlight();
$notesList = window.$('ul.main-notes-list');
Notes.animateAppendNote(note.html, $notesList);
// Update datetime format on the recent note
gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false);
this.collapseLongCommitList();
......@@ -348,7 +350,7 @@ require('./task_list');
lineType = this.isParallelView() ? form.find('#line_type').val() : 'old';
diffAvatarContainer = row.prevAll('.line_holder').first().find('.js-avatar-container.' + lineType + '_line');
// is this the first note of discussion?
discussionContainer = $(".notes[data-discussion-id='" + note.discussion_id + "']");
discussionContainer = window.$(`.notes[data-discussion-id="${note.discussion_id}"]`);
if (!discussionContainer.length) {
discussionContainer = form.closest('.discussion').find('.notes');
}
......@@ -370,14 +372,13 @@ require('./task_list');
row.find(contentContainerClass + ' .content').append($notes.closest('.content').children());
}
}
// Init discussion on 'Discussion' page if it is merge request page
if ($('body').attr('data-page').indexOf('projects:merge_request') === 0 || !note.diff_discussion_html) {
$('ul.main-notes-list').append($(note.discussion_html).renderGFM());
if (window.$('body').attr('data-page').indexOf('projects:merge_request') === 0 || !note.diff_discussion_html) {
Notes.animateAppendNote(note.discussion_html, window.$('ul.main-notes-list'));
}
} else {
// append new note to all matching discussions
discussionContainer.append($(note.html).renderGFM());
Notes.animateAppendNote(note.html, discussionContainer);
}
if (typeof gl.diffNotesCompileComponents !== 'undefined' && note.discussion_resolvable) {
......@@ -1063,6 +1064,13 @@ require('./task_list');
return $form;
};
Notes.animateAppendNote = function(noteHTML, $notesList) {
const $note = window.$(noteHTML);
$note.addClass('fade-in').renderGFM();
$notesList.append($note);
};
return Notes;
})();
}).call(window);
/* global Flash */
import canceledSvg from 'icons/_icon_status_canceled_borderless.svg';
import createdSvg from 'icons/_icon_status_created_borderless.svg';
import failedSvg from 'icons/_icon_status_failed_borderless.svg';
import manualSvg from 'icons/_icon_status_manual_borderless.svg';
import pendingSvg from 'icons/_icon_status_pending_borderless.svg';
import runningSvg from 'icons/_icon_status_running_borderless.svg';
import skippedSvg from 'icons/_icon_status_skipped_borderless.svg';
import successSvg from 'icons/_icon_status_success_borderless.svg';
import warningSvg from 'icons/_icon_status_warning_borderless.svg';
import StatusIconEntityMap from '../../ci_status_icons';
export default {
data() {
const svgsDictionary = {
icon_status_canceled: canceledSvg,
icon_status_created: createdSvg,
icon_status_failed: failedSvg,
icon_status_manual: manualSvg,
icon_status_pending: pendingSvg,
icon_status_running: runningSvg,
icon_status_skipped: skippedSvg,
icon_status_success: successSvg,
icon_status_warning: warningSvg,
};
return {
builds: '',
spinner: '<span class="fa fa-spinner fa-spin"></span>',
svg: svgsDictionary[this.stage.status.icon],
};
},
......@@ -89,6 +68,9 @@ export default {
triggerButtonClass() {
return `mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button ci-status-icon-${this.stage.status.group}`;
},
svgHTML() {
return StatusIconEntityMap[this.stage.status.icon];
},
},
template: `
<div>
......@@ -100,7 +82,7 @@ export default {
data-toggle="dropdown"
type="button"
:aria-label="stage.title">
<span v-html="svg" aria-hidden="true"></span>
<span v-html="svgHTML" aria-hidden="true"></span>
<i class="fa fa-caret-down" aria-hidden="true"></i>
</button>
<ul class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container">
......
function UsagePing() {
const usageDataUrl = $('.usage-data').data('endpoint');
$.ajax({
type: 'GET',
url: usageDataUrl,
dataType: 'html',
success(html) {
$('.usage-data').html(html);
},
});
}
window.gl = window.gl || {};
window.gl.UsagePing = UsagePing;
......@@ -145,3 +145,17 @@ a {
.dropdown-menu-nav a {
transition: none;
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.fade-in {
animation: fadeIn $fade-in-duration 1;
}
......@@ -40,6 +40,10 @@
line-height: 24px;
}
.bold {
font-weight: 600;
}
.tab-content {
overflow: visible;
}
......
......@@ -564,3 +564,7 @@
color: $gl-text-color-secondary;
}
}
.droplab-item-ignore {
pointer-events: none;
}
......@@ -331,6 +331,14 @@ header {
.dropdown-menu-nav {
min-width: 140px;
margin-top: -5px;
.current-user {
padding: 5px 18px;
.user-name {
display: block;
}
}
}
}
......
......@@ -460,6 +460,11 @@ $label-inverse-bg: #333;
$label-remove-border: rgba(0, 0, 0, .1);
$label-border-radius: 100px;
/*
* Animation
*/
$fade-in-duration: 200ms;
/*
* Lint
*/
......
......@@ -210,10 +210,6 @@
}
}
.bold {
font-weight: 600;
}
.light {
font-weight: normal;
}
......
......@@ -289,8 +289,12 @@ table.u2f-registrations {
margin: 0 auto;
.bordered-box {
border: 1px solid $border-color;
border: 1px solid $blue-300;
border-radius: $border-radius-default;
background-color: $blue-25;
position: relative;
display: flex;
justify-content: center;
}
.landing {
......@@ -298,28 +302,59 @@ table.u2f-registrations {
margin-bottom: $gl-padding;
.close {
margin-right: 20px;
}
position: absolute;
right: 20px;
opacity: 1;
.dismiss-icon {
float: right;
cursor: pointer;
color: $blue-300;
}
.dismiss-icon {
float: right;
cursor: pointer;
color: $cycle-analytics-dismiss-icon-color;
&:hover {
background-color: transparent;
border: 0;
.dismiss-icon {
color: $blue-400;
}
}
}
.svg-container {
text-align: center;
margin-right: 30px;
display: inline-block;
svg {
width: 136px;
height: 136px;
height: 110px;
vertical-align: top;
}
}
.user-callout-copy {
display: inline-block;
vertical-align: top;
}
}
@media(max-width: $screen-xs-max) {
.inner-content {
padding-left: 30px;
text-align: center;
.bordered-box {
display: block;
}
.landing {
.svg-container,
.user-callout-copy {
margin: 0;
display: block;
svg {
height: 75px;
}
}
}
}
}
......@@ -604,6 +604,10 @@ pre.light-well {
.avatar-container {
align-self: flex-start;
> a {
width: 100%;
}
}
.project-details {
......
......@@ -105,11 +105,11 @@ class Projects::GitHttpController < Projects::GitHttpClientController
access_check.allowed?
end
def log_user_activity
Users::ActivityService.new(user, 'pull').execute
end
def access_klass
@access_klass ||= wiki? ? Gitlab::GitAccessWiki : Gitlab::GitAccess
end
def log_user_activity
Users::ActivityService.new(user, 'pull').execute
end
end
......@@ -64,6 +64,14 @@ module SortingHelper
}
end
def branches_sort_options_hash
{
sort_value_name => sort_title_name,
sort_value_recently_updated => sort_title_recently_updated,
sort_value_oldest_updated => sort_title_oldest_updated
}
end
def sort_title_priority
'Priority'
end
......
......@@ -23,7 +23,7 @@ module Issuable
included do
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description
cache_markdown_field :description, issuable_state_filter_enabled: true
belongs_to :author, class_name: "User"
belongs_to :assignee, class_name: "User"
......
......@@ -8,6 +8,7 @@ class Identity < ActiveRecord::Base
validates :user_id, uniqueness: { scope: :provider }
scope :with_provider, ->(provider) { where(provider: provider) }
scope :with_extern_uid, ->(provider, extern_uid) { where(extern_uid: extern_uid, provider: provider) }
def ldap?
provider.starts_with?('ldap')
......
......@@ -17,7 +17,7 @@ class Note < ActiveRecord::Base
ignore_column :original_discussion_id
cache_markdown_field :note, pipeline: :note
cache_markdown_field :note, pipeline: :note, issuable_state_filter_enabled: true
# Attribute containing rendered and redacted Markdown as generated by
# Banzai::ObjectRenderer.
......
......@@ -22,7 +22,7 @@ class ChatNotificationService < Service
end
def can_test?
valid?
super && valid?
end
def self.supported_events
......
class StatusEntity < Grape::Entity
include RequestAwareEntity
expose :icon, :favicon, :text, :label, :group
expose :icon, :text, :label, :group
expose :has_details?, as: :has_details
expose :details_path
expose :favicon do |status|
ActionController::Base.helpers.image_path(File.join('ci_favicons', "#{status.favicon}.ico"))
end
end
......@@ -8,9 +8,20 @@ class DeleteMergedBranchesService < BaseService
branches = project.repository.branch_names
branches = branches.select { |branch| project.repository.merged_to_root_ref?(branch) }
# Prevent deletion of branches relevant to open merge requests
branches -= merge_request_branch_names
branches.each do |branch|
DeleteBranchService.new(project, current_user).execute(branch)
end
end
private
def merge_request_branch_names
# reorder(nil) is necessary for SELECT DISTINCT because default scope adds an ORDER BY
source_names = project.origin_merge_requests.opened.reorder(nil).uniq.pluck(:source_branch)
target_names = project.merge_requests.opened.reorder(nil).uniq.pluck(:target_branch)
(source_names + target_names).uniq
end
end
......@@ -515,11 +515,12 @@
= f.check_box :usage_ping_enabled
Usage ping enabled
= link_to icon('question-circle'), help_page_path("user/admin_area/settings/usage_statistics", anchor: "usage-data")
.container
.help-block
Every week GitLab will report license usage back to GitLab, Inc.
Disable this option if you do not want this to occur. This is the JSON payload that will be sent:
%pre.usage-data.js-syntax-highlight.code.highlight{ "data-endpoint" => usage_data_admin_application_settings_path(format: :html) }
.help-block
Every week GitLab will report license usage back to GitLab, Inc.
Disable this option if you do not want this to occur. To see the
JSON payload that will be sent, visit the
= succeed '.' do
= link_to "Cohorts page", admin_cohorts_path(anchor: 'usage-ping')
%fieldset
%legend Email
......
%h2 Usage ping
%h2#usage-ping Usage ping
.bs-callout.clearfix
%p
......
......@@ -79,6 +79,11 @@
= icon('caret-down')
.dropdown-menu-nav.dropdown-menu-align-right
%ul
%li.current-user
.user-name.bold
= current_user.name
@#{current_user.username}
%li.divider
%li
= link_to "Profile", current_user, class: 'profile-link', aria: { label: "Profile" }, data: { user: current_user.username }
%li
......
......@@ -11,13 +11,13 @@
Project
- if project_nav_tab? :files
= nav_link(controller: %w(tree blob blame edit_tree new_tree find_file commit commits compare repositories tags branches releases graphs network path_locks)) do
= nav_link(controller: %w(tree blob blame edit_tree new_tree find_file commit commits compare projects/repositories tags branches releases graphs network path_locks)) do
= link_to project_files_path(@project), title: 'Repository', class: 'shortcuts-tree' do
%span
Repository
- if project_nav_tab? :container_registry
= nav_link(controller: %w(container_registry)) do
= nav_link(controller: %w[projects/registry/repositories]) do
= link_to project_container_registry_path(@project), title: 'Container Registry', class: 'shortcuts-container-registry' do
%span
Registry
......
......@@ -15,16 +15,14 @@
.dropdown.inline>
%button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
%span.light
= projects_sort_options_hash[@sort]
= branches_sort_options_hash[@sort]
= icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-align-right
%li
= link_to filter_branches_path(sort: sort_value_name) do
= sort_title_name
= link_to filter_branches_path(sort: sort_value_recently_updated) do
= sort_title_recently_updated
= link_to filter_branches_path(sort: sort_value_oldest_updated) do
= sort_title_oldest_updated
%ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
%li.dropdown-header
Sort by
- branches_sort_options_hash.each do |value, title|
%li
= link_to title, filter_branches_path(sort: value), class: ("is-active" if @sort == value)
- if can? current_user, :push_code, @project
= link_to namespace_project_merged_branches_path(@project.namespace, @project), class: 'btn btn-inverted btn-remove has-tooltip', title: "Delete all branches that are merged into '#{@project.repository.root_ref}'", method: :delete, data: { confirm: "Deleting the merged branches cannot be undone. Are you sure?", container: 'body' } do
......
......@@ -13,9 +13,6 @@
Environment:
= link_to @environment.name, environment_path(@environment)
.col-sm-6
.nav-controls
= render 'projects/deployments/actions', deployment: @environment.last_deployment
.prometheus-state
.js-getting-started.hidden
.row
......
......@@ -46,7 +46,7 @@
-# This tab is always loaded via AJAX
- if @pipelines.any?
#pipelines.pipelines.tab-pane
= render 'projects/merge_requests/show/pipelines', endpoint: url_for(params.merge(format: :json))
= render 'projects/merge_requests/show/pipelines', endpoint: url_for(params.merge(format: :json)), disable_initialization: true
.mr-loading-status
= spinner
......
- endpoint_path = local_assigns[:endpoint] || pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request, format: :json)
- disable_initialization = local_assigns.fetch(:disable_initialization, false)
= render 'projects/commit/pipelines_list', endpoint: endpoint_path
= render 'projects/commit/pipelines_list', endpoint: endpoint_path, disable_initialization: disable_initialization
......@@ -16,7 +16,7 @@
%p
Add a general comment to this #{noteable_name}.
%li.divider
%li.divider.droplab-item-ignore
%li#discussion{ data: { value: 'DiscussionNote', 'submit-text' => 'Start discussion', 'close-text' => "Start discussion & close #{noteable_name}", 'reopen-text' => "Start discussion & reopen #{noteable_name}" } }
%a{ href: '#' }
......
......@@ -3,12 +3,11 @@
%button.btn.btn-default.close.js-close-callout{ type: 'button',
'aria-label' => 'Dismiss customize experience box' }
= icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
.row
.col-sm-3.col-xs-12.svg-container
= custom_icon('icon_customization')
.col-sm-8.col-xs-12.inner-content
%h4
Customize your experience
%p
Change syntax themes, default project pages, and more in preferences.
= link_to 'Check it out', profile_preferences_path, class: 'btn btn-default js-close-callout'
.svg-container
= custom_icon('icon_customization')
.user-callout-copy
%h4
Customize your experience
%p
Change syntax themes, default project pages, and more in preferences.
= link_to 'Check it out', profile_preferences_path, class: 'btn btn-primary js-close-callout'
......@@ -27,7 +27,8 @@
= visibility_level_icon(group.visibility_level, fw: false)
.avatar-container.s40
= image_tag group_icon(group), class: "avatar s40 hidden-xs"
= link_to group do
= image_tag group_icon(group), class: "avatar s40 hidden-xs"
.title
= link_to group_name, group, class: 'group-name'
......
......@@ -12,10 +12,11 @@
= cache(cache_key) do
- if avatar
.avatar-container.s40
- if use_creator_avatar
= image_tag avatar_icon(project.creator.email, 40), class: "avatar s40", alt:''
- else
= project_icon(project, alt: '', class: 'avatar project-avatar s40')
= link_to project_path(project), class: dom_class(project) do
- if use_creator_avatar
= image_tag avatar_icon(project.creator.email, 40), class: "avatar s40", alt:''
- else
= project_icon(project, alt: '', class: 'avatar project-avatar s40')
.project-details
%h3.prepend-top-0.append-bottom-0
= link_to project_path(project), class: dom_class(project) do
......
......@@ -2,6 +2,8 @@ class SystemHookWorker
include Sidekiq::Worker
include DedicatedSidekiqQueue
sidekiq_options retry: 4
def perform(hook_id, data, hook_name)
SystemHook.find(hook_id).execute(data, hook_name)
end
......
---
title: 29595 Update callout design
merge_request:
author:
---
title: Remove pipeline controls for last deployment from Environment monitoring page
merge_request: 10769
author:
---
title: Added quick-update (fade-in) animation to newly rendered notes
merge_request: 10623
author:
---
title: Added profile name to user dropdown
merge_request:
author:
---
title: Disable test settings on chat notification services when repository is empty
merge_request: 10759
author:
---
title: Display custom hook error messages when automatic merge is enabled
merge_request:
author:
---
title: Removed target blank from the metrics action inside the environments list
merge_request: 10726
author:
---
title: Removed orphaned notification settings without a namespace
merge_request:
author:
---
title: Fixed group milestone date dropdowns not opening
merge_request:
author:
---
title: Add retry to system hook worker
merge_request: 10801
author:
---
title: Fix PlantUML integration in GFM
merge_request: 10651
author:
---
title: Implement search by extern_uid in Users API
merge_request: 10509
author: Robin Bobbitt
---
title: Set the issuable sidebar to remain closed for mobile devices
merge_request:
author:
---
title: Add unique index for notes_id to system note metadata table
merge_request:
author:
---
title: Don't delete a branch involved in an open merge request in "Delete all merged
branches" service
merge_request:
author:
---
title: Add usage ping to CE
merge_request:
author:
......@@ -246,8 +246,8 @@ Settings.gitlab['email_from'] ||= ENV['GITLAB_EMAIL_FROM'] || "gitlab@#{Settings
Settings.gitlab['email_display_name'] ||= ENV['GITLAB_EMAIL_DISPLAY_NAME'] || 'GitLab'
Settings.gitlab['email_reply_to'] ||= ENV['GITLAB_EMAIL_REPLY_TO'] || "noreply@#{Settings.gitlab.host}"
Settings.gitlab['email_subject_suffix'] ||= ENV['GITLAB_EMAIL_SUBJECT_SUFFIX'] || ""
Settings.gitlab['base_url'] ||= Settings.send(:build_base_gitlab_url)
Settings.gitlab['url'] ||= Settings.send(:build_gitlab_url)
Settings.gitlab['base_url'] ||= Settings.__send__(:build_base_gitlab_url)
Settings.gitlab['url'] ||= Settings.__send__(:build_gitlab_url)
Settings.gitlab['user'] ||= 'git'
Settings.gitlab['user_home'] ||= begin
Etc.getpwnam(Settings.gitlab['user']).dir
......@@ -257,7 +257,7 @@ end
Settings.gitlab['time_zone'] ||= nil
Settings.gitlab['signup_enabled'] ||= true if Settings.gitlab['signup_enabled'].nil?
Settings.gitlab['signin_enabled'] ||= true if Settings.gitlab['signin_enabled'].nil?
Settings.gitlab['restricted_visibility_levels'] = Settings.send(:verify_constant_array, Gitlab::VisibilityLevel, Settings.gitlab['restricted_visibility_levels'], [])
Settings.gitlab['restricted_visibility_levels'] = Settings.__send__(:verify_constant_array, Gitlab::VisibilityLevel, Settings.gitlab['restricted_visibility_levels'], [])
Settings.gitlab['username_changing_enabled'] = true if Settings.gitlab['username_changing_enabled'].nil?
Settings.gitlab['issue_closing_pattern'] = '((?:[Cc]los(?:e[sd]?|ing)|[Ff]ix(?:e[sd]|ing)?|[Rr]esolv(?:e[sd]?|ing))(:?) +(?:(?:issues? +)?%{issue_ref}(?:(?:, *| +and +)?)|([A-Z][A-Z0-9_]+-\d+))+)' if Settings.gitlab['issue_closing_pattern'].nil?
Settings.gitlab['default_projects_features'] ||= {}
......@@ -270,7 +270,7 @@ Settings.gitlab.default_projects_features['wiki'] = true if Settin
Settings.gitlab.default_projects_features['snippets'] = true if Settings.gitlab.default_projects_features['snippets'].nil?
Settings.gitlab.default_projects_features['builds'] = true if Settings.gitlab.default_projects_features['builds'].nil?
Settings.gitlab.default_projects_features['container_registry'] = true if Settings.gitlab.default_projects_features['container_registry'].nil?
Settings.gitlab.default_projects_features['visibility_level'] = Settings.send(:verify_constant, Gitlab::VisibilityLevel, Settings.gitlab.default_projects_features['visibility_level'], Gitlab::VisibilityLevel::PRIVATE)
Settings.gitlab.default_projects_features['visibility_level'] = Settings.__send__(:verify_constant, Gitlab::VisibilityLevel, Settings.gitlab.default_projects_features['visibility_level'], Gitlab::VisibilityLevel::PRIVATE)
Settings.gitlab['domain_whitelist'] ||= []
Settings.gitlab['import_sources'] ||= %w[github bitbucket gitlab google_code fogbugz git gitlab_project gitea]
Settings.gitlab['trusted_proxies'] ||= []
......@@ -291,7 +291,7 @@ Settings.gitlab_ci['shared_runners_enabled'] = true if Settings.gitlab_ci['share
Settings.gitlab_ci['all_broken_builds'] = true if Settings.gitlab_ci['all_broken_builds'].nil?
Settings.gitlab_ci['add_pusher'] = false if Settings.gitlab_ci['add_pusher'].nil?
Settings.gitlab_ci['builds_path'] = Settings.absolute(Settings.gitlab_ci['builds_path'] || "builds/")
Settings.gitlab_ci['url'] ||= Settings.send(:build_gitlab_ci_url)
Settings.gitlab_ci['url'] ||= Settings.__send__(:build_gitlab_ci_url)
#
# Reply by email
......@@ -330,7 +330,7 @@ Settings.pages['https'] = false if Settings.pages['https'].nil?
Settings.pages['host'] ||= "example.com"
Settings.pages['port'] ||= Settings.pages.https ? 443 : 80
Settings.pages['protocol'] ||= Settings.pages.https ? "https" : "http"
Settings.pages['url'] ||= Settings.send(:build_pages_url)
Settings.pages['url'] ||= Settings.__send__(:build_pages_url)
Settings.pages['external_http'] ||= false unless Settings.pages['external_http'].present?
Settings.pages['external_https'] ||= false unless Settings.pages['external_https'].present?
......@@ -430,6 +430,14 @@ Settings.cron_jobs['remove_unreferenced_lfs_objects_worker']['job_class'] = 'Rem
Settings.cron_jobs['stuck_import_jobs_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['stuck_import_jobs_worker']['cron'] ||= '15 * * * *'
Settings.cron_jobs['stuck_import_jobs_worker']['job_class'] = 'StuckImportJobsWorker'
Settings.cron_jobs['gitlab_usage_ping_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['gitlab_usage_ping_worker']['cron'] ||= Settings.__send__(:cron_random_weekly_time)
Settings.cron_jobs['gitlab_usage_ping_worker']['job_class'] = 'GitlabUsagePingWorker'
# Every day at 00:30
Settings.cron_jobs['schedule_update_user_activity_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['schedule_update_user_activity_worker']['cron'] ||= '30 0 * * *'
Settings.cron_jobs['schedule_update_user_activity_worker']['job_class'] = 'ScheduleUpdateUserActivityWorker'
Settings.cron_jobs['clear_shared_runners_minutes_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['clear_shared_runners_minutes_worker']['cron'] ||= '0 0 1 * *'
......@@ -453,7 +461,7 @@ Settings.gitlab_shell['ssh_host'] ||= Settings.gitlab.ssh_host
Settings.gitlab_shell['ssh_port'] ||= 22
Settings.gitlab_shell['ssh_user'] ||= Settings.gitlab.user
Settings.gitlab_shell['owner_group'] ||= Settings.gitlab.user
Settings.gitlab_shell['ssh_path_prefix'] ||= Settings.send(:build_gitlab_shell_ssh_path_prefix)
Settings.gitlab_shell['ssh_path_prefix'] ||= Settings.__send__(:build_gitlab_shell_ssh_path_prefix)
#
# Repositories
......
......@@ -101,10 +101,7 @@ namespace :admin do
resource :application_settings, only: [:show, :update] do
resources :services, only: [:index, :edit, :update]
## EE-specific
get :usage_data
## EE-specific
put :reset_runners_token
put :reset_health_check_token
put :clear_repository_check_states
......
......@@ -64,3 +64,4 @@
- [elastic_indexer, 1]
- [elastic_commit_indexer, 1]
- [export_csv, 1]
class AddUsagePingToApplicationSettings < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
add_column :application_settings, :usage_ping_enabled, :boolean, default: true, null: false
end
......
class DeleteOrphanNotificationSettings < ActiveRecord::Migration
DOWNTIME = false
def up
execute("DELETE FROM notification_settings WHERE EXISTS (SELECT true FROM (#{orphan_notification_settings}) AS ns WHERE ns.id = notification_settings.id)")
end
def down
# This is a no-op method to make the migration reversible.
# If someone is trying to rollback for other reasons, we should not throw an Exception.
# raise ActiveRecord::IrreversibleMigration
end
def orphan_notification_settings
<<-SQL
SELECT notification_settings.id
FROM notification_settings
LEFT OUTER JOIN namespaces
ON namespaces.id = notification_settings.source_id
WHERE notification_settings.source_type = 'Namespace'
AND namespaces.id IS NULL
SQL
end
end
class AddIndexToSystemNoteMetadata < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
disable_ddl_transaction!
def up
# MySQL automatically creates an index on a foreign-key constraint; PostgreSQL does not
add_concurrent_index :system_note_metadata, :note_id, unique: true if Gitlab::Database.postgresql?
end
def down
remove_concurrent_index :system_note_metadata, :note_id, unique: true if Gitlab::Database.postgresql?
end
end
......@@ -1343,6 +1343,8 @@ ActiveRecord::Schema.define(version: 20170419065104) do
t.datetime "updated_at", null: false
end
add_index "system_note_metadata", ["note_id"], name: "index_system_note_metadata_on_note_id", unique: true, using: :btree
create_table "taggings", force: :cascade do |t|
t.integer "tag_id"
t.integer "taggable_id"
......
......@@ -80,6 +80,7 @@ All technical content published by GitLab lives in the documentation, including:
- [Sidekiq Troubleshooting](administration/troubleshooting/sidekiq.md) Debug when Sidekiq appears hung and is not processing jobs.
- [System hooks](system_hooks/system_hooks.md) Notifications when users, projects and keys are changed.
- [Update](update/README.md) Update guides to upgrade your installation.
- [User cohorts](user/admin_area/user_cohorts.md) View user activity over time.
- [Web terminals](administration/integration/terminal.md) Provide terminal access to environments from within GitLab.
- [Welcome message](customization/welcome_message.md) Add a custom welcome message to the sign-in page.
- [Downgrade back to CE](downgrade_ee_to_ce/README.md) Follow this guide if you need to downgrade from EE to CE.
......
......@@ -134,6 +134,18 @@ For example:
GET /users?username=jack_smith
```
You can also lookup users by external UID and provider:
```
GET /users?extern_uid=:extern_uid&provider=:provider
```
For example:
```
GET /users?extern_uid=1234567&provider=github
```
You can search for users who are external with: `/users?external=true`
## Single user
......@@ -994,8 +1006,7 @@ Parameters:
### Get user activities (admin only)
>**Note:** This API endpoint is only available on 8.15 EE and above.
>**Note:** This API endpoint is only available on 8.15 (EE) and 9.1 (CE) and above.
Get the last activity date for all users, sorted from oldest to newest.
......@@ -1018,7 +1029,7 @@ Parameters:
| `from` | string | no | Date string in the format YEAR-MONTH-DAY, e.g. `2016-03-11`. Defaults to 6 months ago. |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/user/activities
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/user/activities
```
Example response:
......
# DropLab
A generic dropdown for all of your custom dropdown needs.
## Usage
DropLab can be used by simply adding a `data-dropdown-trigger` HTML attribute.
This attribute allows us to find the "trigger" _(toggle)_ for the dropdown,
whether that is a button, link or input.
The value of the `data-dropdown-trigger` should be a CSS selector that
DropLab can use to find the trigger's dropdown list.
You should also add the `data-dropdown` attribute to declare the dropdown list.
The value is irrelevant.
The DropLab class has no side effects, so you must always call `.init` when
the DOM is ready. `DropLab.prototype.init` takes the same arguments as `DropLab.prototype.addHook`.
If you do not provide any arguments, it will globally query and instantiate all droplab compatible dropdowns.
```html
<a href="#" data-dropdown-trigger="#list">Toggle</a>
<ul id="list" data-dropdown>
<!-- ... -->
<ul>
```
```js
const droplab = new DropLab();
droplab.init();
```
As you can see, we have a "Toggle" link, that is declared as a trigger.
It provides a selector to find the dropdown list it should control.
### Static data
You can add static list items.
```html
<a href="#" data-dropdown-trigger="#list">Toggle</a>
<ul id="list" data-dropdown>
<li>Static value 1</li>
<li>Static value 2</li>
<ul>
```
```js
const droplab = new DropLab();
droplab.init();
```
### Explicit instantiation
You can pass the trigger and list elements as constructor arguments to return
a non-global instance of DropLab using the `DropLab.prototype.init` method.
```html
<a href="#" id="trigger" data-dropdown-trigger="#list">Toggle</a>
<ul id="list" data-dropdown>
<!-- ... -->
<ul>
```
```js
const trigger = document.getElementById('trigger');
const list = document.getElementById('list');
const droplab = new DropLab();
droplab.init(trigger, list);
```
You can also add hooks to an existing DropLab instance using `DropLab.prototype.addHook`.
```html
<a href="#" data-dropdown-trigger="#auto-dropdown">Toggle</a>
<ul id="auto-dropdown" data-dropdown><!-- ... --><ul>
<a href="#" id="trigger" data-dropdown-trigger="#list">Toggle</a>
<ul id="list" data-dropdown><!-- ... --><ul>
```
```js
const droplab = new DropLab();
droplab.init();
const trigger = document.getElementById('trigger');
const list = document.getElementById('list');
droplab.addHook(trigger, list);
```
### Dynamic data
Adding `data-dynamic` to your dropdown element will enable dynamic list rendering.
You can template a list item using the keys of the data object provided.
Use the handlebars syntax `{{ value }}` to HTML escape the value.
Use the `<%= value %>` syntax to simply interpolate the value.
Use the `<%= value %>` syntax to evaluate the value.
Passing an array of objects to `DropLab.prototype.addData` will render that data
for all `data-dynamic` dropdown lists tracked by that DropLab instance.
```html
<a href="#" data-dropdown-trigger="#list">Toggle</a>
<ul id="list" data-dropdown data-dynamic>
<li><a href="#" data-id="{{id}}">{{text}}</a></li>
</ul>
```
```js
const droplab = new DropLab();
droplab.init().addData([{
id: 0,
text: 'Jacob',
}, {
id: 1,
text: 'Jeff',
}]);
```
Alternatively, you can specify a specific dropdown to add this data to but passing
the data as the second argument and and the `id` of the trigger element as the first argument.
```html
<a href="#" data-dropdown-trigger="#list" id="trigger">Toggle</a>
<ul id="list" data-dropdown data-dynamic>
<li><a href="#" data-id="{{id}}">{{text}}</a></li>
</ul>
```
```js
const droplab = new DropLab();
droplab.init().addData('trigger', [{
id: 0,
text: 'Jacob',
}, {
id: 1,
text: 'Jeff',
}]);
```
This allows you to mix static and dynamic content with ease, even with one trigger.
Note the use of scoping regarding the `data-dropdown` attribute to capture both
dropdown lists, one of which is dynamic.
```html
<input id="trigger" data-dropdown-trigger="#list">
<div id="list" data-dropdown>
<ul>
<li><a href="#">Static item 1</a></li>
<li><a href="#">Static item 2</a></li>
</ul>
<ul data-dynamic>
<li><a href="#" data-id="{{id}}">{{text}}</a></li>
</ul>
</div>
```
```js
const droplab = new DropLab();
droplab.init().addData('trigger', [{
id: 0,
text: 'Jacob',
}, {
id: 1,
text: 'Jeff',
}]);
```
## Internal selectors
DropLab adds some CSS classes to help lower the barrier to integration.
For example,
* The `droplab-item-selected` css class is added to items that have been selected
either by a mouse click or by enter key selection.
* The `droplab-item-active` css class is added to items that have been selected
using arrow key navigation.
## Internal events
DropLab uses some custom events to help lower the barrier to integration.
For example,
* The `click.dl` event is fired when an `li` list item has been clicked. It is also
fired when a list item has been selected with the keyboard. It is also fired when a
`HookButton` button is clicked (a registered `button` tag or `a` tag trigger).
* The `input.dl` event is fired when a `HookInput` (a registered `input` tag trigger) triggers an `input` event.
* The `mousedown.dl` event is fired when a `HookInput` triggers a `mousedown` event.
* The `keyup.dl` event is fired when a `HookInput` triggers a `keyup` event.
* The `keydown.dl` event is fired when a `HookInput` triggers a `keydown` event.
These custom events add a `detail` object to the vanilla `Event` object that provides some potentially useful data.
## Plugins
Plugins are objects that are registered to be executed when a hook is added (when a droplab trigger and dropdown are instantiated).
If no modules API is detected, the library will fall back as it does with `window.DropLab` and will add `window.DropLab.plugins.PluginName`.
### Usage
To use plugins, you can pass them in an array as the third argument of `DropLab.prototype.init` or `DropLab.prototype.addHook`.
Some plugins require configuration values, the config object can be passed as the fourth argument.
```html
<a href="#" id="trigger" data-dropdown-trigger="#list">Toggle</a>
<ul id="list" data-dropdown><!-- ... --><ul>
```
```js
const droplab = new DropLab();
const trigger = document.getElementById('trigger');
const list = document.getElementById('list');
droplab.init(trigger, list, [droplabAjax], {
droplabAjax: {
endpoint: '/some-endpoint',
method: 'setData',
},
});
```
### Documentation
* [Ajax plugin](plugins/ajax.md)
* [Filter plugin](plugins/filter.md)
* [InputSetter plugin](plugins/input_setter.md)
### Development
When plugins are initialised for a droplab trigger+dropdown, DropLab will
call the plugins `init` function, so this must be implemented in the plugin.
```js
class MyPlugin {
static init() {
this.someProp = 'someProp';
this.someMethod();
}
static someMethod() {
this.otherProp = 'otherProp';
}
}
export default MyPlugin;
```
# Ajax
`Ajax` is a droplab plugin that allows for retrieving and rendering list data from a server.
## Usage
Add the `Ajax` object to the plugins array of a `DropLab.prototype.init` or `DropLab.prototype.addHook` call.
`Ajax` requires 2 config values, the `endpoint` and `method`.
* `endpoint` should be a URL to the request endpoint.
* `method` should be `setData` or `addData`.
* `setData` completely replaces the dropdown with the response data.
* `addData` appends the response data to the current dropdown list.
```html
<a href="#" id="trigger" data-dropdown-trigger="#list">Toggle</a>
<ul id="list" data-dropdown><!-- ... --><ul>
```
```js
const droplab = new DropLab();
const trigger = document.getElementById('trigger');
const list = document.getElementById('list');
droplab.addHook(trigger, list, [Ajax], {
Ajax: {
endpoint: '/some-endpoint',
method: 'setData',
},
});
```
Optionally you can set `loadingTemplate` to a HTML string. This HTML string will
replace the dropdown list whilst the request is pending.
Additionally, you can set `onError` to a function to catch any XHR errors.
# Filter
`Filter` is a plugin that allows for filtering data that has been added
to the dropdown using a simple fuzzy string search of an input value.
## Usage
Add the `Filter` object to the plugins array of a `DropLab.prototype.init` or `DropLab.prototype.addHook` call.
* `Filter` requires a config value for `template`.
* `template` should be the key of the objects within your data array that you want to compare
to the user input string, for filtering.
```html
<input href="#" id="trigger" data-dropdown-trigger="#list">
<ul id="list" data-dropdown data-dynamic>
<li><a href="#" data-id="{{id}}">{{text}}</a></li>
<ul>
```
```js
const droplab = new DropLab();
const trigger = document.getElementById('trigger');
const list = document.getElementById('list');
droplab.init(trigger, list, [Filter], {
Filter: {
template: 'text',
},
});
droplab.addData('trigger', [{
id: 0,
text: 'Jacob',
}, {
id: 1,
text: 'Jeff',
}]);
```
Above, the input string will be compared against the `test` key of the passed data objects.
Optionally you can set `filterFunction` to a function. This function will be used instead
of `Filter`'s built in string search. `filterFunction` is passed 2 arguments, the first
is one of the data objects, the second is the current input value.
# InputSetter
`InputSetter` is a plugin that allows for udating DOM out of the scope of droplab when a list item is clicked.
## Usage
Add the `InputSetter` object to the plugins array of a `DropLab.prototype.init` or `DropLab.prototype.addHook` call.
* `InputSetter` requires a config value for `input` and `valueAttribute`.
* `input` should be the DOM element that you want to manipulate.
* `valueAttribute` should be a string that is the name of an attribute on your list items that is used to get the value
to update the `input` element with.
You can also set the `InputSetter` config to an array of objects, which will allow you to update multiple elements.
```html
<input id="input" value="">
<div id="div" data-selected-id=""></div>
<input href="#" id="trigger" data-dropdown-trigger="#list">
<ul id="list" data-dropdown data-dynamic>
<li><a href="#" data-id="{{id}}">{{text}}</a></li>
<ul>
```
```js
const droplab = new DropLab();
const trigger = document.getElementById('trigger');
const list = document.getElementById('list');
const input = document.getElementById('input');
const div = document.getElementById('div');
droplab.init(trigger, list, [InputSetter], {
InputSetter: [{
input: input,
valueAttribute: 'data-id',
} {
input: div,
valueAttribute: 'data-id',
inputAttribute: 'data-selected-id',
}],
});
droplab.addData('trigger', [{
id: 0,
text: 'Jacob',
}, {
id: 1,
text: 'Jeff',
}]);
```
Above, if the second list item was clicked, it would update the `#input` element
to have a `value` of `1`, it would also update the `#div` element's `data-selected-id` to `1`.
Optionally you can set `inputAttribute` to a string that is the name of an attribute on your `input` element that you want to update.
If you do not provide an `inputAttribute`, `InputSetter` will update the `value` of the `input` element if it is an `INPUT` element,
or the `textContent` of the `input` element if it is not an `INPUT` element.
......@@ -90,3 +90,13 @@ Our accessibility standards and resources.
[scss-lint]: https://github.com/brigade/scss-lint
[install]: ../../install/installation.md#4-node
[requirements]: ../../install/requirements.md#supported-web-browsers
---
## [DropLab](droplab/droplab.md)
Our internal `DropLab` dropdown library.
* [DropLab](droplab/droplab.md)
* [Ajax plugin](droplab/plugins/ajax.md)
* [Filter plugin](droplab/plugins/filter.md)
* [InputSetter plugin](droplab/plugins/input_setter.md)
......@@ -75,6 +75,7 @@ The total number of the following is sent back to GitLab Inc.:
- Remote mirrors
- Service Desk projects
- Service Desk issues
- Uploads
- Web hooks
Also, we track if you've installed Mattermost with GitLab.
......@@ -96,7 +97,7 @@ GitLab Inc. does **not** collect any sensitive information, like project names
or the content of the comments. GitLab Inc. does not disclose or otherwise make
available any of the data collected on a customer specific basis.
Read more in about the [Privacy policy](https://about.gitlab.com/privacy).
Read more about this in the [Privacy policy](https://about.gitlab.com/privacy).
[ee-557]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/557
[ee-735]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/735
......
# GitLab Groups
GitLab groups allow you to group projects into directories and give users to several projects at once.
GitLab groups allow you to group projects into directories and give users access to several projects at once.
When you create a new project in GitLab, the default namespace for the project is the personal namespace associated with your GitLab user.
In this document we will see how to create groups, put projects in groups and manage who can access the projects in a group.
......
......@@ -177,9 +177,3 @@ Feature: Project Issues
And I should not see labels field
And I submit new issue "500 error on profile"
Then I should see issue "500 error on profile"
@javascript
Scenario: Another user adds a comment to issue I'm currently viewing
Given I visit issue page "Release 0.4"
And another user adds a comment with text "Yay!" to issue "Release 0.4"
Then I should see a new comment with text "Yay!"
......@@ -87,7 +87,7 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps
end
step 'I click on the "Remove User From Group" button for "John Doe"' do
find(:css, 'li', text: "John Doe").find(:css, 'a.btn-remove').click
find(:css, '.project-members-page li', text: "John Doe").find(:css, 'a.btn-remove').click
# poltergeist always confirms popups.
end
......@@ -97,7 +97,7 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps
end
step 'I should not see the "Remove User From Group" button for "John Doe"' do
expect(find(:css, 'li', text: "John Doe")).not_to have_selector(:css, 'a.btn-remove')
expect(find(:css, '.project-members-page li', text: "John Doe")).not_to have_selector(:css, 'a.btn-remove')
# poltergeist always confirms popups.
end
......
......@@ -345,17 +345,6 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
end
end
step 'another user adds a comment with text "Yay!" to issue "Release 0.4"' do
issue = Issue.find_by!(title: 'Release 0.4')
create(:note_on_issue, noteable: issue, project: project, note: 'Yay!')
end
step 'I should see a new comment with text "Yay!"' do
page.within '#notes' do
expect(page).to have_content('Yay!')
end
end
def filter_issue(text)
fill_in 'issuable_search', with: text
end
......
......@@ -53,12 +53,6 @@ module API
]
end
def log_user_activity(actor)
commands = Gitlab::GitAccess::DOWNLOAD_COMMANDS
::Users::ActivityService.new(actor, 'Git SSH').execute if commands.include?(params[:action])
end
def parse_env
return {} if params[:env].blank?
......@@ -66,6 +60,12 @@ module API
rescue JSON::ParserError
{}
end
def log_user_activity(actor)
commands = Gitlab::GitAccess::DOWNLOAD_COMMANDS
::Users::ActivityService.new(actor, 'Git SSH').execute if commands.include?(params[:action])
end
end
end
end
......@@ -39,10 +39,13 @@ module API
params do
# CE
optional :username, type: String, desc: 'Get a single user with a specific username'
optional :extern_uid, type: String, desc: 'Get a single user with a specific external authentication provider UID'
optional :provider, type: String, desc: 'The external provider'
optional :search, type: String, desc: 'Search for a username'
optional :active, type: Boolean, default: false, desc: 'Filters only active users'
optional :external, type: Boolean, default: false, desc: 'Filters only external users'
optional :blocked, type: Boolean, default: false, desc: 'Filters only blocked users'
all_or_none_of :extern_uid, :provider
# EE
optional :skip_ldap, type: Boolean, default: false, desc: 'Skip LDAP users'
......@@ -54,15 +57,18 @@ module API
render_api_error!("Not authorized.", 403)
end
if params[:username].present?
users = User.where(username: params[:username])
else
users = User.all
users = users.active if params[:active]
users = users.non_ldap if params[:skip_ldap]
users = users.search(params[:search]) if params[:search].present?
users = users.blocked if params[:blocked]
users = users.external if params[:external] && current_user.admin?
authenticated_as_admin! if params[:external].present? || (params[:extern_uid].present? && params[:provider].present?)
users = User.all
users = User.where(username: params[:username]) if params[:username]
users = users.active if params[:active]
users = users.search(params[:search]) if params[:search].present?
users = users.blocked if params[:blocked]
users = users.non_ldap if params[:skip_ldap]
if current_user.admin?
users = users.joins(:identities).merge(Identity.with_extern_uid(params[:provider], params[:extern_uid])) if params[:extern_uid] && params[:provider]
users = users.external if params[:external]
end
entity = current_user.admin? ? Entities::UserPublic : Entities::UserBasic
......
......@@ -9,12 +9,14 @@ module Banzai
VISIBLE_STATES = %w(closed merged).freeze
def call
return doc unless context[:issuable_state_filter_enabled]
extractor = Banzai::IssuableExtractor.new(project, current_user)
issuables = extractor.extract([doc])
issuables.each do |node, issuable|
if VISIBLE_STATES.include?(issuable.state) && node.children.present?
node.add_child(Nokogiri::XML::Text.new(" [#{issuable.state}]", doc))
if VISIBLE_STATES.include?(issuable.state) && node.inner_html == issuable.reference_link_text(project)
node.content += " (#{issuable.state})"
end
end
......
......@@ -7,14 +7,14 @@ module Banzai
#
class PlantumlFilter < HTML::Pipeline::Filter
def call
return doc unless doc.at('pre.plantuml') && settings.plantuml_enabled
return doc unless doc.at('pre > code[lang="plantuml"]') && settings.plantuml_enabled
plantuml_setup
doc.css('pre.plantuml').each do |el|
doc.css('pre > code[lang="plantuml"]').each do |node|
img_tag = Nokogiri::HTML::DocumentFragment.parse(
Asciidoctor::PlantUml::Processor.plantuml_content(el.content, {}))
el.replace img_tag
Asciidoctor::PlantUml::Processor.plantuml_content(node.content, {}))
node.parent.replace(img_tag)
end
doc
......
......@@ -48,7 +48,7 @@ module ContainerRegistry
end
def root_repository?
@path == repository_project.full_path
@path == project_path
end
def repository_project
......@@ -60,7 +60,13 @@ module ContainerRegistry
def repository_name
return unless has_project?
@path.remove(%r(^#{Regexp.escape(repository_project.full_path)}/?))
@path.remove(%r(^#{Regexp.escape(project_path)}/?))
end
def project_path
return unless has_project?
repository_project.full_path.downcase
end
def to_s
......
......@@ -15,7 +15,7 @@ module Gitlab
end
unless URI(address).scheme.in?(%w(tcp unix))
raise "Unsupported Gitaly address: #{address.inspect}"
raise "Unsupported Gitaly address: #{address.inspect} does not use URL scheme 'tcp' or 'unix'"
end
@addresses[name] = address
......
module Gitlab
module MarkupHelper
module_function
extend self
MARKDOWN_EXTENSIONS = %w(mdown mkd mkdn md markdown).freeze
ASCIIDOC_EXTENSIONS = %w(adoc ad asciidoc).freeze
OTHER_EXTENSIONS = %w(textile rdoc org creole wiki mediawiki rst).freeze
EXTENSIONS = MARKDOWN_EXTENSIONS + ASCIIDOC_EXTENSIONS + OTHER_EXTENSIONS
# Public: Determines if a given filename is compatible with GitHub::Markup.
#
......@@ -8,10 +13,7 @@ module Gitlab
#
# Returns boolean
def markup?(filename)
gitlab_markdown?(filename) ||
asciidoc?(filename) ||
filename.downcase.end_with?(*%w(.textile .rdoc .org .creole .wiki
.mediawiki .rst))
EXTENSIONS.include?(extension(filename))
end
# Public: Determines if a given filename is compatible with
......@@ -21,7 +23,7 @@ module Gitlab
#
# Returns boolean
def gitlab_markdown?(filename)
filename.downcase.end_with?(*%w(.mdown .mkd .mkdn .md .markdown))
MARKDOWN_EXTENSIONS.include?(extension(filename))
end
# Public: Determines if the given filename has AsciiDoc extension.
......@@ -30,7 +32,7 @@ module Gitlab
#
# Returns boolean
def asciidoc?(filename)
filename.downcase.end_with?(*%w(.adoc .ad .asciidoc))
ASCIIDOC_EXTENSIONS.include?(extension(filename))
end
# Public: Determines if the given filename is plain text.
......@@ -39,12 +41,17 @@ module Gitlab
#
# Returns boolean
def plain?(filename)
filename.downcase.end_with?('.txt') ||
filename.casecmp('readme').zero?
extension(filename) == 'txt' || filename.casecmp('readme').zero?
end
def previewable?(filename)
markup?(filename)
end
private
def extension(filename)
File.extname(filename).downcase.delete('.')
end
end
end
......@@ -3,6 +3,8 @@ require 'spec_helper'
describe Admin::ApplicationSettingsController do
include StubENV
let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
let(:admin) { create(:admin) }
let(:user) { create(:user)}
......@@ -10,6 +12,40 @@ describe Admin::ApplicationSettingsController do
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
end
describe 'GET #usage_data with no access' do
before do
sign_in(user)
end
it 'returns 404' do
get :usage_data, format: :html
expect(response.status).to eq(404)
end
end
describe 'GET #usage_data' do
before do
sign_in(admin)
end
it 'returns HTML data' do
get :usage_data, format: :html
expect(response.body).to start_with('<span')
expect(response.status).to eq(200)
end
it 'returns JSON data' do
get :usage_data, format: :json
body = JSON.parse(response.body)
expect(body["version"]).to eq(Gitlab::VERSION)
expect(body).to include('counts')
expect(response.status).to eq(200)
end
end
describe 'PUT #update' do
before do
sign_in(admin)
......
......@@ -60,7 +60,7 @@ describe Projects::BuildsController do
expect(json_response['text']).to eq status.text
expect(json_response['label']).to eq status.label
expect(json_response['icon']).to eq status.icon
expect(json_response['favicon']).to eq status.favicon
expect(json_response['favicon']).to eq "/assets/ci_favicons/#{status.favicon}.ico"
end
end
end
......@@ -1497,7 +1497,7 @@ describe Projects::MergeRequestsController do
expect(json_response['text']).to eq status.text
expect(json_response['label']).to eq status.label
expect(json_response['icon']).to eq status.icon
expect(json_response['favicon']).to eq status.favicon
expect(json_response['favicon']).to eq "/assets/ci_favicons/#{status.favicon}.ico"
end
end
......
......@@ -86,7 +86,7 @@ describe Projects::PipelinesController do
expect(json_response['text']).to eq status.text
expect(json_response['label']).to eq status.label
expect(json_response['icon']).to eq status.icon
expect(json_response['favicon']).to eq status.favicon
expect(json_response['favicon']).to eq "/assets/ci_favicons/#{status.favicon}.ico"
end
end
end
......@@ -40,6 +40,10 @@ FactoryGirl.define do
state :closed
end
trait :opened do
state :opened
end
trait :reopened do
state :reopened
end
......
......@@ -109,7 +109,7 @@ describe "Admin::Projects", feature: true do
expect(page).to have_content('Developer')
end
find(:css, 'li', text: current_user.name).find(:css, 'a.btn-remove').click
find(:css, '.content-list li', text: current_user.name).find(:css, 'a.btn-remove').click
expect(page).not_to have_selector(:css, '.content-list')
end
......
require 'rails_helper'
feature 'Group milestones', :feature, :js do
let(:group) { create(:group) }
let!(:project) { create(:project_empty_repo, group: group) }
let(:user) { create(:group_member, :master, user: create(:user), group: group ).user }
before do
Timecop.freeze
login_as(user)
end
after do
Timecop.return
end
context 'create a milestone' do
before do
visit new_group_milestone_path(group)
end
it 'creates milestone with start date' do
fill_in 'Title', with: 'testing'
find('#milestone_start_date').click
page.within(find('.pika-single')) do
click_button '1'
end
click_button 'Create milestone'
expect(find('.start_date')).to have_content(Date.today.at_beginning_of_month.strftime('%b %-d, %Y'))
end
end
end
......@@ -120,6 +120,20 @@ feature 'Issue Sidebar', feature: true do
end
end
context 'as a allowed mobile user', js: true do
before do
project.team << [user, :developer]
resize_screen_xs
visit_issue(project, issue)
end
context 'mobile sidebar' do
it 'collapses the sidebar for small screens' do
expect(page).not_to have_css('aside.right-sidebar.right-sidebar-collapsed')
end
end
end
context 'as a guest' do
before do
project.team << [user, :guest]
......
require 'spec_helper'
feature 'Issue notes polling' do
let!(:project) { create(:project, :public) }
let!(:issue) { create(:issue, project: project) }
feature 'Issue notes polling', :feature, :js do
let(:project) { create(:empty_project, :public) }
let(:issue) { create(:issue, project: project) }
background do
before do
visit namespace_project_issue_path(project.namespace, project, issue)
end
scenario 'Another user adds a comment to an issue', js: true do
note = create(:note, noteable: issue, project: project,
note: 'Looks good!')
it 'should display the new comment' do
note = create(:note, noteable: issue, project: project, note: 'Looks good!')
page.execute_script('notes.refresh();')
expect(page).to have_selector("#note_#{note.id}", text: 'Looks good!')
......
import * as icons from '~/ci_status_icons';
describe('CI status icons', () => {
const statuses = [
'canceled',
'created',
'failed',
'manual',
'pending',
'running',
'skipped',
'success',
'warning',
];
statuses.forEach((status) => {
it(`should export a ${status} svg`, () => {
const key = `${status.toUpperCase()}_SVG`;
expect(Object.hasOwnProperty.call(icons, key)).toBe(true);
expect(icons[key]).toMatch(/^<svg/);
});
});
describe('default export map', () => {
const entityIconNames = [
'icon_status_canceled',
'icon_status_created',
'icon_status_failed',
'icon_status_manual',
'icon_status_pending',
'icon_status_running',
'icon_status_skipped',
'icon_status_success',
'icon_status_warning',
];
entityIconNames.forEach((iconName) => {
it(`should have a '${iconName}' key`, () => {
expect(Object.hasOwnProperty.call(icons.default, iconName)).toBe(true);
});
});
});
});
......@@ -26,4 +26,10 @@ describe('constants', function () {
expect(constants.ACTIVE_CLASS).toBe('droplab-item-active');
});
});
describe('IGNORE_CLASS', function () {
it('should be `droplab-item-ignore`', function() {
expect(constants.IGNORE_CLASS).toBe('droplab-item-ignore');
});
});
});
......@@ -2,7 +2,7 @@
import DropDown from '~/droplab/drop_down';
import utils from '~/droplab/utils';
import { SELECTED_CLASS } from '~/droplab/constants';
import { SELECTED_CLASS, IGNORE_CLASS } from '~/droplab/constants';
describe('DropDown', function () {
describe('class constructor', function () {
......@@ -128,9 +128,10 @@ describe('DropDown', function () {
describe('clickEvent', function () {
beforeEach(function () {
this.classList = jasmine.createSpyObj('classList', ['contains']);
this.list = { dispatchEvent: () => {} };
this.dropdown = { hide: () => {}, list: this.list, addSelectedClass: () => {} };
this.event = { preventDefault: () => {}, target: {} };
this.event = { preventDefault: () => {}, target: { classList: this.classList } };
this.customEvent = {};
this.closestElement = {};
......@@ -140,6 +141,7 @@ describe('DropDown', function () {
spyOn(this.event, 'preventDefault');
spyOn(window, 'CustomEvent').and.returnValue(this.customEvent);
spyOn(utils, 'closest').and.returnValues(this.closestElement, undefined);
this.classList.contains.and.returnValue(false);
DropDown.prototype.clickEvent.call(this.dropdown, this.event);
});
......@@ -164,15 +166,35 @@ describe('DropDown', function () {
expect(window.CustomEvent).toHaveBeenCalledWith('click.dl', jasmine.any(Object));
});
it('should call .classList.contains checking for IGNORE_CLASS', function () {
expect(this.classList.contains).toHaveBeenCalledWith(IGNORE_CLASS);
});
it('should call .dispatchEvent with the customEvent', function () {
expect(this.list.dispatchEvent).toHaveBeenCalledWith(this.customEvent);
});
describe('if the target is a UL element', function () {
beforeEach(function () {
this.event = { preventDefault: () => {}, target: { tagName: 'UL' } };
this.event = { preventDefault: () => {}, target: { tagName: 'UL', classList: this.classList } };
spyOn(this.event, 'preventDefault');
utils.closest.calls.reset();
DropDown.prototype.clickEvent.call(this.dropdown, this.event);
});
it('should return immediately', function () {
expect(utils.closest).not.toHaveBeenCalled();
});
});
describe('if the target has the IGNORE_CLASS class', function () {
beforeEach(function () {
this.event = { preventDefault: () => {}, target: { tagName: 'LI', classList: this.classList } };
spyOn(this.event, 'preventDefault');
this.classList.contains.and.returnValue(true);
utils.closest.calls.reset();
DropDown.prototype.clickEvent.call(this.dropdown, this.event);
......
import Vue from 'vue';
import externalUrlComp from '~/environments/components/environment_external_url';
import externalUrlComp from '~/environments/components/environment_external_url.vue';
describe('External URL Component', () => {
let ExternalUrlComponent;
......
import Vue from 'vue';
import stopComp from '~/environments/components/environment_stop';
import stopComp from '~/environments/components/environment_stop.vue';
describe('Stop Component', () => {
let StopComponent;
......
import Vue from 'vue';
import terminalComp from '~/environments/components/environment_terminal_button';
import terminalComp from '~/environments/components/environment_terminal_button.vue';
describe('Stop Component', () => {
let TerminalComponent;
......
......@@ -3,69 +3,67 @@ require('~/filtered_search/filtered_search_tokenizer');
require('~/filtered_search/filtered_search_dropdown');
require('~/filtered_search/dropdown_user');
(() => {
describe('Dropdown User', () => {
describe('getSearchInput', () => {
let dropdownUser;
describe('Dropdown User', () => {
describe('getSearchInput', () => {
let dropdownUser;
beforeEach(() => {
spyOn(gl.DropdownUser.prototype, 'bindEvents').and.callFake(() => {});
spyOn(gl.DropdownUser.prototype, 'getProjectId').and.callFake(() => {});
spyOn(gl.DropdownUtils, 'getSearchInput').and.callFake(() => {});
beforeEach(() => {
spyOn(gl.DropdownUser.prototype, 'bindEvents').and.callFake(() => {});
spyOn(gl.DropdownUser.prototype, 'getProjectId').and.callFake(() => {});
spyOn(gl.DropdownUtils, 'getSearchInput').and.callFake(() => {});
dropdownUser = new gl.DropdownUser();
});
it('should not return the double quote found in value', () => {
spyOn(gl.FilteredSearchTokenizer, 'processTokens').and.returnValue({
lastToken: '"johnny appleseed',
});
dropdownUser = new gl.DropdownUser();
});
expect(dropdownUser.getSearchInput()).toBe('johnny appleseed');
it('should not return the double quote found in value', () => {
spyOn(gl.FilteredSearchTokenizer, 'processTokens').and.returnValue({
lastToken: '"johnny appleseed',
});
it('should not return the single quote found in value', () => {
spyOn(gl.FilteredSearchTokenizer, 'processTokens').and.returnValue({
lastToken: '\'larry boy',
});
expect(dropdownUser.getSearchInput()).toBe('johnny appleseed');
});
expect(dropdownUser.getSearchInput()).toBe('larry boy');
it('should not return the single quote found in value', () => {
spyOn(gl.FilteredSearchTokenizer, 'processTokens').and.returnValue({
lastToken: '\'larry boy',
});
expect(dropdownUser.getSearchInput()).toBe('larry boy');
});
});
describe('config AjaxFilter\'s endpoint', () => {
beforeEach(() => {
spyOn(gl.DropdownUser.prototype, 'bindEvents').and.callFake(() => {});
spyOn(gl.DropdownUser.prototype, 'getProjectId').and.callFake(() => {});
});
describe('config AjaxFilter\'s endpoint', () => {
beforeEach(() => {
spyOn(gl.DropdownUser.prototype, 'bindEvents').and.callFake(() => {});
spyOn(gl.DropdownUser.prototype, 'getProjectId').and.callFake(() => {});
});
it('should return endpoint', () => {
window.gon = {
relative_url_root: '',
};
const dropdown = new gl.DropdownUser();
it('should return endpoint', () => {
window.gon = {
relative_url_root: '',
};
const dropdown = new gl.DropdownUser();
expect(dropdown.config.AjaxFilter.endpoint).toBe('/autocomplete/users.json');
});
expect(dropdown.config.AjaxFilter.endpoint).toBe('/autocomplete/users.json');
});
it('should return endpoint when relative_url_root is undefined', () => {
const dropdown = new gl.DropdownUser();
it('should return endpoint when relative_url_root is undefined', () => {
const dropdown = new gl.DropdownUser();
expect(dropdown.config.AjaxFilter.endpoint).toBe('/autocomplete/users.json');
});
expect(dropdown.config.AjaxFilter.endpoint).toBe('/autocomplete/users.json');
});
it('should return endpoint with relative url when available', () => {
window.gon = {
relative_url_root: '/gitlab_directory',
};
const dropdown = new gl.DropdownUser();
it('should return endpoint with relative url when available', () => {
window.gon = {
relative_url_root: '/gitlab_directory',
};
const dropdown = new gl.DropdownUser();
expect(dropdown.config.AjaxFilter.endpoint).toBe('/gitlab_directory/autocomplete/users.json');
});
expect(dropdown.config.AjaxFilter.endpoint).toBe('/gitlab_directory/autocomplete/users.json');
});
afterEach(() => {
window.gon = {};
});
afterEach(() => {
window.gon = {};
});
});
})();
});
......@@ -3,308 +3,306 @@ require('~/filtered_search/dropdown_utils');
require('~/filtered_search/filtered_search_tokenizer');
require('~/filtered_search/filtered_search_dropdown_manager');
(() => {
describe('Dropdown Utils', () => {
describe('getEscapedText', () => {
it('should return same word when it has no space', () => {
const escaped = gl.DropdownUtils.getEscapedText('textWithoutSpace');
expect(escaped).toBe('textWithoutSpace');
});
describe('Dropdown Utils', () => {
describe('getEscapedText', () => {
it('should return same word when it has no space', () => {
const escaped = gl.DropdownUtils.getEscapedText('textWithoutSpace');
expect(escaped).toBe('textWithoutSpace');
});
it('should escape with double quotes', () => {
let escaped = gl.DropdownUtils.getEscapedText('text with space');
expect(escaped).toBe('"text with space"');
it('should escape with double quotes', () => {
let escaped = gl.DropdownUtils.getEscapedText('text with space');
expect(escaped).toBe('"text with space"');
escaped = gl.DropdownUtils.getEscapedText('won\'t fix');
expect(escaped).toBe('"won\'t fix"');
});
escaped = gl.DropdownUtils.getEscapedText('won\'t fix');
expect(escaped).toBe('"won\'t fix"');
});
it('should escape with single quotes', () => {
const escaped = gl.DropdownUtils.getEscapedText('won"t fix');
expect(escaped).toBe('\'won"t fix\'');
});
it('should escape with single quotes', () => {
const escaped = gl.DropdownUtils.getEscapedText('won"t fix');
expect(escaped).toBe('\'won"t fix\'');
});
it('should escape with single quotes by default', () => {
const escaped = gl.DropdownUtils.getEscapedText('won"t\' fix');
expect(escaped).toBe('\'won"t\' fix\'');
});
it('should escape with single quotes by default', () => {
const escaped = gl.DropdownUtils.getEscapedText('won"t\' fix');
expect(escaped).toBe('\'won"t\' fix\'');
});
});
describe('filterWithSymbol', () => {
let input;
const item = {
title: '@root',
};
describe('filterWithSymbol', () => {
let input;
const item = {
title: '@root',
};
beforeEach(() => {
setFixtures(`
<input type="text" id="test" />
`);
beforeEach(() => {
setFixtures(`
<input type="text" id="test" />
`);
input = document.getElementById('test');
});
input = document.getElementById('test');
});
it('should filter without symbol', () => {
input.value = 'roo';
it('should filter without symbol', () => {
input.value = 'roo';
const updatedItem = gl.DropdownUtils.filterWithSymbol('@', input, item);
expect(updatedItem.droplab_hidden).toBe(false);
});
const updatedItem = gl.DropdownUtils.filterWithSymbol('@', input, item);
expect(updatedItem.droplab_hidden).toBe(false);
});
it('should filter with symbol', () => {
input.value = '@roo';
it('should filter with symbol', () => {
input.value = '@roo';
const updatedItem = gl.DropdownUtils.filterWithSymbol('@', input, item);
expect(updatedItem.droplab_hidden).toBe(false);
});
const updatedItem = gl.DropdownUtils.filterWithSymbol('@', input, item);
expect(updatedItem.droplab_hidden).toBe(false);
});
describe('filters multiple word title', () => {
const multipleWordItem = {
title: 'Community Contributions',
};
describe('filters multiple word title', () => {
const multipleWordItem = {
title: 'Community Contributions',
};
it('should filter with double quote', () => {
input.value = '"';
it('should filter with double quote', () => {
input.value = '"';
const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
it('should filter with double quote and symbol', () => {
input.value = '~"';
it('should filter with double quote and symbol', () => {
input.value = '~"';
const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
it('should filter with double quote and multiple words', () => {
input.value = '"community con';
it('should filter with double quote and multiple words', () => {
input.value = '"community con';
const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
it('should filter with double quote, symbol and multiple words', () => {
input.value = '~"community con';
it('should filter with double quote, symbol and multiple words', () => {
input.value = '~"community con';
const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
it('should filter with single quote', () => {
input.value = '\'';
it('should filter with single quote', () => {
input.value = '\'';
const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
it('should filter with single quote and symbol', () => {
input.value = '~\'';
it('should filter with single quote and symbol', () => {
input.value = '~\'';
const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
it('should filter with single quote and multiple words', () => {
input.value = '\'community con';
it('should filter with single quote and multiple words', () => {
input.value = '\'community con';
const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
it('should filter with single quote, symbol and multiple words', () => {
input.value = '~\'community con';
it('should filter with single quote, symbol and multiple words', () => {
input.value = '~\'community con';
const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem);
expect(updatedItem.droplab_hidden).toBe(false);
});
});
});
describe('filterHint', () => {
let input;
beforeEach(() => {
setFixtures(`
<ul class="tokens-container">
<li class="input-token">
<input class="filtered-search" type="text" id="test" />
</li>
</ul>
`);
input = document.getElementById('test');
});
describe('filterHint', () => {
let input;
it('should filter', () => {
input.value = 'l';
let updatedItem = gl.DropdownUtils.filterHint(input, {
hint: 'label',
});
expect(updatedItem.droplab_hidden).toBe(false);
beforeEach(() => {
setFixtures(`
<ul class="tokens-container">
<li class="input-token">
<input class="filtered-search" type="text" id="test" />
</li>
</ul>
`);
input.value = 'o';
updatedItem = gl.DropdownUtils.filterHint(input, {
hint: 'label',
});
expect(updatedItem.droplab_hidden).toBe(true);
});
input = document.getElementById('test');
});
it('should return droplab_hidden false when item has no hint', () => {
const updatedItem = gl.DropdownUtils.filterHint(input, {}, '');
expect(updatedItem.droplab_hidden).toBe(false);
it('should filter', () => {
input.value = 'l';
let updatedItem = gl.DropdownUtils.filterHint(input, {
hint: 'label',
});
expect(updatedItem.droplab_hidden).toBe(false);
it('should allow multiple if item.type is array', () => {
input.value = 'label:~first la';
const updatedItem = gl.DropdownUtils.filterHint(input, {
hint: 'label',
type: 'array',
});
expect(updatedItem.droplab_hidden).toBe(false);
input.value = 'o';
updatedItem = gl.DropdownUtils.filterHint(input, {
hint: 'label',
});
expect(updatedItem.droplab_hidden).toBe(true);
});
it('should prevent multiple if item.type is not array', () => {
input.value = 'milestone:~first mile';
let updatedItem = gl.DropdownUtils.filterHint(input, {
hint: 'milestone',
});
expect(updatedItem.droplab_hidden).toBe(true);
it('should return droplab_hidden false when item has no hint', () => {
const updatedItem = gl.DropdownUtils.filterHint(input, {}, '');
expect(updatedItem.droplab_hidden).toBe(false);
});
updatedItem = gl.DropdownUtils.filterHint(input, {
hint: 'milestone',
type: 'string',
});
expect(updatedItem.droplab_hidden).toBe(true);
it('should allow multiple if item.type is array', () => {
input.value = 'label:~first la';
const updatedItem = gl.DropdownUtils.filterHint(input, {
hint: 'label',
type: 'array',
});
expect(updatedItem.droplab_hidden).toBe(false);
});
describe('setDataValueIfSelected', () => {
beforeEach(() => {
spyOn(gl.FilteredSearchDropdownManager, 'addWordToInput')
.and.callFake(() => {});
it('should prevent multiple if item.type is not array', () => {
input.value = 'milestone:~first mile';
let updatedItem = gl.DropdownUtils.filterHint(input, {
hint: 'milestone',
});
expect(updatedItem.droplab_hidden).toBe(true);
it('calls addWordToInput when dataValue exists', () => {
const selected = {
getAttribute: () => 'value',
};
gl.DropdownUtils.setDataValueIfSelected(null, selected);
expect(gl.FilteredSearchDropdownManager.addWordToInput.calls.count()).toEqual(1);
updatedItem = gl.DropdownUtils.filterHint(input, {
hint: 'milestone',
type: 'string',
});
expect(updatedItem.droplab_hidden).toBe(true);
});
});
it('returns true when dataValue exists', () => {
const selected = {
getAttribute: () => 'value',
};
describe('setDataValueIfSelected', () => {
beforeEach(() => {
spyOn(gl.FilteredSearchDropdownManager, 'addWordToInput')
.and.callFake(() => {});
});
const result = gl.DropdownUtils.setDataValueIfSelected(null, selected);
expect(result).toBe(true);
});
it('calls addWordToInput when dataValue exists', () => {
const selected = {
getAttribute: () => 'value',
};
it('returns false when dataValue does not exist', () => {
const selected = {
getAttribute: () => null,
};
gl.DropdownUtils.setDataValueIfSelected(null, selected);
expect(gl.FilteredSearchDropdownManager.addWordToInput.calls.count()).toEqual(1);
});
const result = gl.DropdownUtils.setDataValueIfSelected(null, selected);
expect(result).toBe(false);
});
it('returns true when dataValue exists', () => {
const selected = {
getAttribute: () => 'value',
};
const result = gl.DropdownUtils.setDataValueIfSelected(null, selected);
expect(result).toBe(true);
});
describe('getInputSelectionPosition', () => {
describe('word with trailing spaces', () => {
const value = 'label:none ';
it('returns false when dataValue does not exist', () => {
const selected = {
getAttribute: () => null,
};
const result = gl.DropdownUtils.setDataValueIfSelected(null, selected);
expect(result).toBe(false);
});
});
it('should return selectionStart when cursor is at the trailing space', () => {
const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
selectionStart: 11,
value,
});
describe('getInputSelectionPosition', () => {
describe('word with trailing spaces', () => {
const value = 'label:none ';
expect(left).toBe(11);
expect(right).toBe(11);
it('should return selectionStart when cursor is at the trailing space', () => {
const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
selectionStart: 11,
value,
});
it('should return input when cursor is at the start of input', () => {
const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
selectionStart: 0,
value,
});
expect(left).toBe(11);
expect(right).toBe(11);
});
expect(left).toBe(0);
expect(right).toBe(10);
it('should return input when cursor is at the start of input', () => {
const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
selectionStart: 0,
value,
});
it('should return input when cursor is at the middle of input', () => {
const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
selectionStart: 7,
value,
});
expect(left).toBe(0);
expect(right).toBe(10);
});
expect(left).toBe(0);
expect(right).toBe(10);
it('should return input when cursor is at the middle of input', () => {
const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
selectionStart: 7,
value,
});
it('should return input when cursor is at the end of input', () => {
const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
selectionStart: 10,
value,
});
expect(left).toBe(0);
expect(right).toBe(10);
});
expect(left).toBe(0);
expect(right).toBe(10);
it('should return input when cursor is at the end of input', () => {
const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
selectionStart: 10,
value,
});
});
describe('multiple words', () => {
const value = 'label:~"Community Contribution"';
expect(left).toBe(0);
expect(right).toBe(10);
});
});
it('should return input when cursor is after the first word', () => {
const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
selectionStart: 17,
value,
});
describe('multiple words', () => {
const value = 'label:~"Community Contribution"';
expect(left).toBe(0);
expect(right).toBe(31);
it('should return input when cursor is after the first word', () => {
const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
selectionStart: 17,
value,
});
it('should return input when cursor is before the second word', () => {
const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
selectionStart: 18,
value,
});
expect(left).toBe(0);
expect(right).toBe(31);
});
expect(left).toBe(0);
expect(right).toBe(31);
it('should return input when cursor is before the second word', () => {
const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
selectionStart: 18,
value,
});
});
describe('incomplete multiple words', () => {
const value = 'label:~"Community Contribution';
expect(left).toBe(0);
expect(right).toBe(31);
});
});
it('should return entire input when cursor is at the start of input', () => {
const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
selectionStart: 0,
value,
});
describe('incomplete multiple words', () => {
const value = 'label:~"Community Contribution';
expect(left).toBe(0);
expect(right).toBe(30);
it('should return entire input when cursor is at the start of input', () => {
const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
selectionStart: 0,
value,
});
it('should return entire input when cursor is at the end of input', () => {
const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
selectionStart: 30,
value,
});
expect(left).toBe(0);
expect(right).toBe(30);
});
expect(left).toBe(0);
expect(right).toBe(30);
it('should return entire input when cursor is at the end of input', () => {
const { left, right } = gl.DropdownUtils.getInputSelectionPosition({
selectionStart: 30,
value,
});
expect(left).toBe(0);
expect(right).toBe(30);
});
});
});
})();
});
......@@ -3,99 +3,97 @@ require('~/filtered_search/filtered_search_visual_tokens');
require('~/filtered_search/filtered_search_tokenizer');
require('~/filtered_search/filtered_search_dropdown_manager');
(() => {
describe('Filtered Search Dropdown Manager', () => {
describe('addWordToInput', () => {
function getInputValue() {
return document.querySelector('.filtered-search').value;
}
function setInputValue(value) {
document.querySelector('.filtered-search').value = value;
}
beforeEach(() => {
setFixtures(`
<ul class="tokens-container">
<li class="input-token">
<input class="filtered-search">
</li>
</ul>
`);
});
describe('Filtered Search Dropdown Manager', () => {
describe('addWordToInput', () => {
function getInputValue() {
return document.querySelector('.filtered-search').value;
}
function setInputValue(value) {
document.querySelector('.filtered-search').value = value;
}
beforeEach(() => {
setFixtures(`
<ul class="tokens-container">
<li class="input-token">
<input class="filtered-search">
</li>
</ul>
`);
});
describe('input has no existing value', () => {
it('should add just tokenName', () => {
gl.FilteredSearchDropdownManager.addWordToInput('milestone');
describe('input has no existing value', () => {
it('should add just tokenName', () => {
gl.FilteredSearchDropdownManager.addWordToInput('milestone');
const token = document.querySelector('.tokens-container .js-visual-token');
const token = document.querySelector('.tokens-container .js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toBe('milestone');
expect(getInputValue()).toBe('');
});
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toBe('milestone');
expect(getInputValue()).toBe('');
});
it('should add tokenName and tokenValue', () => {
gl.FilteredSearchDropdownManager.addWordToInput('label');
it('should add tokenName and tokenValue', () => {
gl.FilteredSearchDropdownManager.addWordToInput('label');
let token = document.querySelector('.tokens-container .js-visual-token');
let token = document.querySelector('.tokens-container .js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toBe('label');
expect(getInputValue()).toBe('');
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toBe('label');
expect(getInputValue()).toBe('');
gl.FilteredSearchDropdownManager.addWordToInput('label', 'none');
// We have to get that reference again
// Because gl.FilteredSearchDropdownManager deletes the previous token
token = document.querySelector('.tokens-container .js-visual-token');
gl.FilteredSearchDropdownManager.addWordToInput('label', 'none');
// We have to get that reference again
// Because gl.FilteredSearchDropdownManager deletes the previous token
token = document.querySelector('.tokens-container .js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toBe('label');
expect(token.querySelector('.value').innerText).toBe('none');
expect(getInputValue()).toBe('');
});
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toBe('label');
expect(token.querySelector('.value').innerText).toBe('none');
expect(getInputValue()).toBe('');
});
});
describe('input has existing value', () => {
it('should be able to just add tokenName', () => {
setInputValue('a');
gl.FilteredSearchDropdownManager.addWordToInput('author');
describe('input has existing value', () => {
it('should be able to just add tokenName', () => {
setInputValue('a');
gl.FilteredSearchDropdownManager.addWordToInput('author');
const token = document.querySelector('.tokens-container .js-visual-token');
const token = document.querySelector('.tokens-container .js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toBe('author');
expect(getInputValue()).toBe('');
});
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toBe('author');
expect(getInputValue()).toBe('');
});
it('should replace tokenValue', () => {
gl.FilteredSearchDropdownManager.addWordToInput('author');
it('should replace tokenValue', () => {
gl.FilteredSearchDropdownManager.addWordToInput('author');
setInputValue('roo');
gl.FilteredSearchDropdownManager.addWordToInput(null, '@root');
setInputValue('roo');
gl.FilteredSearchDropdownManager.addWordToInput(null, '@root');
const token = document.querySelector('.tokens-container .js-visual-token');
const token = document.querySelector('.tokens-container .js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toBe('author');
expect(token.querySelector('.value').innerText).toBe('@root');
expect(getInputValue()).toBe('');
});
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toBe('author');
expect(token.querySelector('.value').innerText).toBe('@root');
expect(getInputValue()).toBe('');
});
it('should add tokenValues containing spaces', () => {
gl.FilteredSearchDropdownManager.addWordToInput('label');
it('should add tokenValues containing spaces', () => {
gl.FilteredSearchDropdownManager.addWordToInput('label');
setInputValue('"test ');
gl.FilteredSearchDropdownManager.addWordToInput('label', '~\'"test me"\'');
setInputValue('"test ');
gl.FilteredSearchDropdownManager.addWordToInput('label', '~\'"test me"\'');
const token = document.querySelector('.tokens-container .js-visual-token');
const token = document.querySelector('.tokens-container .js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toBe('label');
expect(token.querySelector('.value').innerText).toBe('~\'"test me"\'');
expect(getInputValue()).toBe('');
});
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toBe('label');
expect(token.querySelector('.value').innerText).toBe('~\'"test me"\'');
expect(getInputValue()).toBe('');
});
});
});
})();
});
......@@ -6,271 +6,269 @@ require('~/filtered_search/filtered_search_dropdown_manager');
require('~/filtered_search/filtered_search_manager');
const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper');
(() => {
describe('Filtered Search Manager', () => {
let input;
let manager;
let tokensContainer;
const placeholder = 'Search or filter results...';
function dispatchBackspaceEvent(element, eventType) {
const backspaceKey = 8;
const event = new Event(eventType);
event.keyCode = backspaceKey;
element.dispatchEvent(event);
}
describe('Filtered Search Manager', () => {
let input;
let manager;
let tokensContainer;
const placeholder = 'Search or filter results...';
function dispatchBackspaceEvent(element, eventType) {
const backspaceKey = 8;
const event = new Event(eventType);
event.keyCode = backspaceKey;
element.dispatchEvent(event);
}
function dispatchDeleteEvent(element, eventType) {
const deleteKey = 46;
const event = new Event(eventType);
event.keyCode = deleteKey;
element.dispatchEvent(event);
}
beforeEach(() => {
setFixtures(`
<div class="filtered-search-box">
<form>
<ul class="tokens-container list-unstyled">
${FilteredSearchSpecHelper.createInputHTML(placeholder)}
</ul>
<button class="clear-search" type="button">
<i class="fa fa-times"></i>
</button>
</form>
</div>
`);
spyOn(gl.FilteredSearchManager.prototype, 'loadSearchParamsFromURL').and.callFake(() => {});
spyOn(gl.FilteredSearchManager.prototype, 'tokenChange').and.callFake(() => {});
spyOn(gl.FilteredSearchDropdownManager.prototype, 'setDropdown').and.callFake(() => {});
spyOn(gl.FilteredSearchDropdownManager.prototype, 'updateDropdownOffset').and.callFake(() => {});
spyOn(gl.utils, 'getParameterByName').and.returnValue(null);
spyOn(gl.FilteredSearchVisualTokens, 'unselectTokens').and.callThrough();
input = document.querySelector('.filtered-search');
tokensContainer = document.querySelector('.tokens-container');
manager = new gl.FilteredSearchManager();
});
function dispatchDeleteEvent(element, eventType) {
const deleteKey = 46;
const event = new Event(eventType);
event.keyCode = deleteKey;
element.dispatchEvent(event);
}
afterEach(() => {
manager.cleanup();
});
beforeEach(() => {
setFixtures(`
<div class="filtered-search-box">
<form>
<ul class="tokens-container list-unstyled">
${FilteredSearchSpecHelper.createInputHTML(placeholder)}
</ul>
<button class="clear-search" type="button">
<i class="fa fa-times"></i>
</button>
</form>
</div>
`);
describe('search', () => {
const defaultParams = '?scope=all&utf8=%E2%9C%93&state=opened';
spyOn(gl.FilteredSearchManager.prototype, 'loadSearchParamsFromURL').and.callFake(() => {});
spyOn(gl.FilteredSearchManager.prototype, 'tokenChange').and.callFake(() => {});
spyOn(gl.FilteredSearchDropdownManager.prototype, 'setDropdown').and.callFake(() => {});
spyOn(gl.FilteredSearchDropdownManager.prototype, 'updateDropdownOffset').and.callFake(() => {});
spyOn(gl.utils, 'getParameterByName').and.returnValue(null);
spyOn(gl.FilteredSearchVisualTokens, 'unselectTokens').and.callThrough();
it('should search with a single word', (done) => {
input.value = 'searchTerm';
input = document.querySelector('.filtered-search');
tokensContainer = document.querySelector('.tokens-container');
manager = new gl.FilteredSearchManager();
});
spyOn(gl.utils, 'visitUrl').and.callFake((url) => {
expect(url).toEqual(`${defaultParams}&search=searchTerm`);
done();
});
afterEach(() => {
manager.cleanup();
manager.search();
});
describe('search', () => {
const defaultParams = '?scope=all&utf8=%E2%9C%93&state=opened';
it('should search with a single word', (done) => {
input.value = 'searchTerm';
it('should search with multiple words', (done) => {
input.value = 'awesome search terms';
spyOn(gl.utils, 'visitUrl').and.callFake((url) => {
expect(url).toEqual(`${defaultParams}&search=searchTerm`);
done();
});
manager.search();
spyOn(gl.utils, 'visitUrl').and.callFake((url) => {
expect(url).toEqual(`${defaultParams}&search=awesome+search+terms`);
done();
});
it('should search with multiple words', (done) => {
input.value = 'awesome search terms';
manager.search();
});
spyOn(gl.utils, 'visitUrl').and.callFake((url) => {
expect(url).toEqual(`${defaultParams}&search=awesome+search+terms`);
done();
});
it('should search with special characters', (done) => {
input.value = '~!@#$%^&*()_+{}:<>,.?/';
manager.search();
spyOn(gl.utils, 'visitUrl').and.callFake((url) => {
expect(url).toEqual(`${defaultParams}&search=~!%40%23%24%25%5E%26*()_%2B%7B%7D%3A%3C%3E%2C.%3F%2F`);
done();
});
it('should search with special characters', (done) => {
input.value = '~!@#$%^&*()_+{}:<>,.?/';
manager.search();
});
spyOn(gl.utils, 'visitUrl').and.callFake((url) => {
expect(url).toEqual(`${defaultParams}&search=~!%40%23%24%25%5E%26*()_%2B%7B%7D%3A%3C%3E%2C.%3F%2F`);
done();
});
it('removes duplicated tokens', (done) => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')}
`);
manager.search();
spyOn(gl.utils, 'visitUrl').and.callFake((url) => {
expect(url).toEqual(`${defaultParams}&label_name[]=bug`);
done();
});
it('removes duplicated tokens', (done) => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')}
`);
spyOn(gl.utils, 'visitUrl').and.callFake((url) => {
expect(url).toEqual(`${defaultParams}&label_name[]=bug`);
done();
});
manager.search();
});
});
manager.search();
});
describe('handleInputPlaceholder', () => {
it('should render placeholder when there is no input', () => {
expect(input.placeholder).toEqual(placeholder);
});
describe('handleInputPlaceholder', () => {
it('should render placeholder when there is no input', () => {
expect(input.placeholder).toEqual(placeholder);
});
it('should not render placeholder when there is input', () => {
input.value = 'test words';
const event = new Event('input');
input.dispatchEvent(event);
it('should not render placeholder when there is input', () => {
input.value = 'test words';
expect(input.placeholder).toEqual('');
});
const event = new Event('input');
input.dispatchEvent(event);
it('should not render placeholder when there are tokens and no input', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'),
);
expect(input.placeholder).toEqual('');
});
const event = new Event('input');
input.dispatchEvent(event);
it('should not render placeholder when there are tokens and no input', () => {
expect(input.placeholder).toEqual('');
});
});
describe('checkForBackspace', () => {
describe('tokens and no input', () => {
beforeEach(() => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'),
);
const event = new Event('input');
input.dispatchEvent(event);
expect(input.placeholder).toEqual('');
});
});
describe('checkForBackspace', () => {
describe('tokens and no input', () => {
beforeEach(() => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'),
);
});
it('removes last token', () => {
spyOn(gl.FilteredSearchVisualTokens, 'removeLastTokenPartial').and.callThrough();
dispatchBackspaceEvent(input, 'keyup');
expect(gl.FilteredSearchVisualTokens.removeLastTokenPartial).toHaveBeenCalled();
});
it('sets the input', () => {
spyOn(gl.FilteredSearchVisualTokens, 'getLastTokenPartial').and.callThrough();
dispatchDeleteEvent(input, 'keyup');
it('removes last token', () => {
spyOn(gl.FilteredSearchVisualTokens, 'removeLastTokenPartial').and.callThrough();
dispatchBackspaceEvent(input, 'keyup');
expect(gl.FilteredSearchVisualTokens.getLastTokenPartial).toHaveBeenCalled();
expect(input.value).toEqual('~bug');
});
expect(gl.FilteredSearchVisualTokens.removeLastTokenPartial).toHaveBeenCalled();
});
it('does not remove token or change input when there is existing input', () => {
spyOn(gl.FilteredSearchVisualTokens, 'removeLastTokenPartial').and.callThrough();
it('sets the input', () => {
spyOn(gl.FilteredSearchVisualTokens, 'getLastTokenPartial').and.callThrough();
input.value = 'text';
dispatchDeleteEvent(input, 'keyup');
expect(gl.FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled();
expect(gl.FilteredSearchVisualTokens.getLastTokenPartial).not.toHaveBeenCalled();
expect(input.value).toEqual('text');
expect(gl.FilteredSearchVisualTokens.getLastTokenPartial).toHaveBeenCalled();
expect(input.value).toEqual('~bug');
});
});
describe('removeSelectedToken', () => {
function getVisualTokens() {
return tokensContainer.querySelectorAll('.js-visual-token');
}
it('does not remove token or change input when there is existing input', () => {
spyOn(gl.FilteredSearchVisualTokens, 'removeLastTokenPartial').and.callThrough();
spyOn(gl.FilteredSearchVisualTokens, 'getLastTokenPartial').and.callThrough();
beforeEach(() => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true),
);
});
input.value = 'text';
dispatchDeleteEvent(input, 'keyup');
it('removes selected token when the backspace key is pressed', () => {
expect(getVisualTokens().length).toEqual(1);
expect(gl.FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled();
expect(gl.FilteredSearchVisualTokens.getLastTokenPartial).not.toHaveBeenCalled();
expect(input.value).toEqual('text');
});
});
dispatchBackspaceEvent(document, 'keydown');
describe('removeSelectedToken', () => {
function getVisualTokens() {
return tokensContainer.querySelectorAll('.js-visual-token');
}
expect(getVisualTokens().length).toEqual(0);
});
beforeEach(() => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true),
);
});
it('removes selected token when the delete key is pressed', () => {
expect(getVisualTokens().length).toEqual(1);
it('removes selected token when the backspace key is pressed', () => {
expect(getVisualTokens().length).toEqual(1);
dispatchDeleteEvent(document, 'keydown');
dispatchBackspaceEvent(document, 'keydown');
expect(getVisualTokens().length).toEqual(0);
});
expect(getVisualTokens().length).toEqual(0);
});
it('updates the input placeholder after removal', () => {
manager.handleInputPlaceholder();
it('removes selected token when the delete key is pressed', () => {
expect(getVisualTokens().length).toEqual(1);
expect(input.placeholder).toEqual('');
expect(getVisualTokens().length).toEqual(1);
dispatchDeleteEvent(document, 'keydown');
dispatchBackspaceEvent(document, 'keydown');
expect(getVisualTokens().length).toEqual(0);
});
expect(input.placeholder).not.toEqual('');
expect(getVisualTokens().length).toEqual(0);
});
it('updates the input placeholder after removal', () => {
manager.handleInputPlaceholder();
it('updates the clear button after removal', () => {
manager.toggleClearSearchButton();
expect(input.placeholder).toEqual('');
expect(getVisualTokens().length).toEqual(1);
const clearButton = document.querySelector('.clear-search');
dispatchBackspaceEvent(document, 'keydown');
expect(clearButton.classList.contains('hidden')).toEqual(false);
expect(getVisualTokens().length).toEqual(1);
expect(input.placeholder).not.toEqual('');
expect(getVisualTokens().length).toEqual(0);
});
dispatchBackspaceEvent(document, 'keydown');
it('updates the clear button after removal', () => {
manager.toggleClearSearchButton();
expect(clearButton.classList.contains('hidden')).toEqual(true);
expect(getVisualTokens().length).toEqual(0);
});
const clearButton = document.querySelector('.clear-search');
expect(clearButton.classList.contains('hidden')).toEqual(false);
expect(getVisualTokens().length).toEqual(1);
dispatchBackspaceEvent(document, 'keydown');
expect(clearButton.classList.contains('hidden')).toEqual(true);
expect(getVisualTokens().length).toEqual(0);
});
});
describe('unselects token', () => {
beforeEach(() => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug', true)}
${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~awesome')}
`);
});
describe('unselects token', () => {
beforeEach(() => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug', true)}
${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~awesome')}
`);
});
it('unselects token when input is clicked', () => {
const selectedToken = tokensContainer.querySelector('.js-visual-token .selected');
it('unselects token when input is clicked', () => {
const selectedToken = tokensContainer.querySelector('.js-visual-token .selected');
expect(selectedToken.classList.contains('selected')).toEqual(true);
expect(gl.FilteredSearchVisualTokens.unselectTokens).not.toHaveBeenCalled();
expect(selectedToken.classList.contains('selected')).toEqual(true);
expect(gl.FilteredSearchVisualTokens.unselectTokens).not.toHaveBeenCalled();
// Click directly on input attached to document
// so that the click event will propagate properly
document.querySelector('.filtered-search').click();
// Click directly on input attached to document
// so that the click event will propagate properly
document.querySelector('.filtered-search').click();
expect(gl.FilteredSearchVisualTokens.unselectTokens).toHaveBeenCalled();
expect(selectedToken.classList.contains('selected')).toEqual(false);
});
expect(gl.FilteredSearchVisualTokens.unselectTokens).toHaveBeenCalled();
expect(selectedToken.classList.contains('selected')).toEqual(false);
});
it('unselects token when document.body is clicked', () => {
const selectedToken = tokensContainer.querySelector('.js-visual-token .selected');
it('unselects token when document.body is clicked', () => {
const selectedToken = tokensContainer.querySelector('.js-visual-token .selected');
expect(selectedToken.classList.contains('selected')).toEqual(true);
expect(gl.FilteredSearchVisualTokens.unselectTokens).not.toHaveBeenCalled();
expect(selectedToken.classList.contains('selected')).toEqual(true);
expect(gl.FilteredSearchVisualTokens.unselectTokens).not.toHaveBeenCalled();
document.body.click();
document.body.click();
expect(selectedToken.classList.contains('selected')).toEqual(false);
expect(gl.FilteredSearchVisualTokens.unselectTokens).toHaveBeenCalled();
});
expect(selectedToken.classList.contains('selected')).toEqual(false);
expect(gl.FilteredSearchVisualTokens.unselectTokens).toHaveBeenCalled();
});
});
describe('toggleInputContainerFocus', () => {
it('toggles on focus', () => {
input.focus();
expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual(true);
});
describe('toggleInputContainerFocus', () => {
it('toggles on focus', () => {
input.focus();
expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual(true);
});
it('toggles on blur', () => {
input.blur();
expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual(false);
});
it('toggles on blur', () => {
input.blur();
expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual(false);
});
});
})();
});
require('~/extensions/array');
require('~/filtered_search/filtered_search_token_keys');
(() => {
describe('Filtered Search Token Keys', () => {
describe('get', () => {
let tokenKeys;
beforeEach(() => {
tokenKeys = gl.FilteredSearchTokenKeys.get();
});
it('should return tokenKeys', () => {
expect(tokenKeys !== null).toBe(true);
});
it('should return tokenKeys as an array', () => {
expect(tokenKeys instanceof Array).toBe(true);
});
});
describe('getConditions', () => {
let conditions;
beforeEach(() => {
conditions = gl.FilteredSearchTokenKeys.getConditions();
});
it('should return conditions', () => {
expect(conditions !== null).toBe(true);
});
it('should return conditions as an array', () => {
expect(conditions instanceof Array).toBe(true);
});
});
describe('searchByKey', () => {
it('should return null when key not found', () => {
const tokenKey = gl.FilteredSearchTokenKeys.searchByKey('notakey');
expect(tokenKey === null).toBe(true);
});
it('should return tokenKey when found by key', () => {
const tokenKeys = gl.FilteredSearchTokenKeys.get();
const result = gl.FilteredSearchTokenKeys.searchByKey(tokenKeys[0].key);
expect(result).toEqual(tokenKeys[0]);
});
});
describe('searchBySymbol', () => {
it('should return null when symbol not found', () => {
const tokenKey = gl.FilteredSearchTokenKeys.searchBySymbol('notasymbol');
expect(tokenKey === null).toBe(true);
});
it('should return tokenKey when found by symbol', () => {
const tokenKeys = gl.FilteredSearchTokenKeys.get();
const result = gl.FilteredSearchTokenKeys.searchBySymbol(tokenKeys[0].symbol);
expect(result).toEqual(tokenKeys[0]);
});
});
describe('searchByKeyParam', () => {
it('should return null when key param not found', () => {
const tokenKey = gl.FilteredSearchTokenKeys.searchByKeyParam('notakeyparam');
expect(tokenKey === null).toBe(true);
});
it('should return tokenKey when found by key param', () => {
const tokenKeys = gl.FilteredSearchTokenKeys.get();
const result = gl.FilteredSearchTokenKeys.searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`);
expect(result).toEqual(tokenKeys[0]);
});
it('should return alternative tokenKey when found by key param', () => {
const tokenKeys = gl.FilteredSearchTokenKeys.getAlternatives();
const result = gl.FilteredSearchTokenKeys.searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`);
expect(result).toEqual(tokenKeys[0]);
});
});
describe('searchByConditionUrl', () => {
it('should return null when condition url not found', () => {
const condition = gl.FilteredSearchTokenKeys.searchByConditionUrl(null);
expect(condition === null).toBe(true);
});
it('should return condition when found by url', () => {
const conditions = gl.FilteredSearchTokenKeys.getConditions();
const result = gl.FilteredSearchTokenKeys.searchByConditionUrl(conditions[0].url);
expect(result).toBe(conditions[0]);
});
});
describe('searchByConditionKeyValue', () => {
it('should return null when condition tokenKey and value not found', () => {
const condition = gl.FilteredSearchTokenKeys.searchByConditionKeyValue(null, null);
expect(condition === null).toBe(true);
});
it('should return condition when found by tokenKey and value', () => {
const conditions = gl.FilteredSearchTokenKeys.getConditions();
const result = gl.FilteredSearchTokenKeys
.searchByConditionKeyValue(conditions[0].tokenKey, conditions[0].value);
expect(result).toEqual(conditions[0]);
});
describe('Filtered Search Token Keys', () => {
describe('get', () => {
let tokenKeys;
beforeEach(() => {
tokenKeys = gl.FilteredSearchTokenKeys.get();
});
it('should return tokenKeys', () => {
expect(tokenKeys !== null).toBe(true);
});
it('should return tokenKeys as an array', () => {
expect(tokenKeys instanceof Array).toBe(true);
});
});
describe('getConditions', () => {
let conditions;
beforeEach(() => {
conditions = gl.FilteredSearchTokenKeys.getConditions();
});
it('should return conditions', () => {
expect(conditions !== null).toBe(true);
});
it('should return conditions as an array', () => {
expect(conditions instanceof Array).toBe(true);
});
});
describe('searchByKey', () => {
it('should return null when key not found', () => {
const tokenKey = gl.FilteredSearchTokenKeys.searchByKey('notakey');
expect(tokenKey === null).toBe(true);
});
it('should return tokenKey when found by key', () => {
const tokenKeys = gl.FilteredSearchTokenKeys.get();
const result = gl.FilteredSearchTokenKeys.searchByKey(tokenKeys[0].key);
expect(result).toEqual(tokenKeys[0]);
});
});
describe('searchBySymbol', () => {
it('should return null when symbol not found', () => {
const tokenKey = gl.FilteredSearchTokenKeys.searchBySymbol('notasymbol');
expect(tokenKey === null).toBe(true);
});
it('should return tokenKey when found by symbol', () => {
const tokenKeys = gl.FilteredSearchTokenKeys.get();
const result = gl.FilteredSearchTokenKeys.searchBySymbol(tokenKeys[0].symbol);
expect(result).toEqual(tokenKeys[0]);
});
});
describe('searchByKeyParam', () => {
it('should return null when key param not found', () => {
const tokenKey = gl.FilteredSearchTokenKeys.searchByKeyParam('notakeyparam');
expect(tokenKey === null).toBe(true);
});
it('should return tokenKey when found by key param', () => {
const tokenKeys = gl.FilteredSearchTokenKeys.get();
const result = gl.FilteredSearchTokenKeys.searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`);
expect(result).toEqual(tokenKeys[0]);
});
it('should return alternative tokenKey when found by key param', () => {
const tokenKeys = gl.FilteredSearchTokenKeys.getAlternatives();
const result = gl.FilteredSearchTokenKeys.searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`);
expect(result).toEqual(tokenKeys[0]);
});
});
describe('searchByConditionUrl', () => {
it('should return null when condition url not found', () => {
const condition = gl.FilteredSearchTokenKeys.searchByConditionUrl(null);
expect(condition === null).toBe(true);
});
it('should return condition when found by url', () => {
const conditions = gl.FilteredSearchTokenKeys.getConditions();
const result = gl.FilteredSearchTokenKeys.searchByConditionUrl(conditions[0].url);
expect(result).toBe(conditions[0]);
});
});
describe('searchByConditionKeyValue', () => {
it('should return null when condition tokenKey and value not found', () => {
const condition = gl.FilteredSearchTokenKeys.searchByConditionKeyValue(null, null);
expect(condition === null).toBe(true);
});
it('should return condition when found by tokenKey and value', () => {
const conditions = gl.FilteredSearchTokenKeys.getConditions();
const result = gl.FilteredSearchTokenKeys
.searchByConditionKeyValue(conditions[0].tokenKey, conditions[0].value);
expect(result).toEqual(conditions[0]);
});
});
})();
});
......@@ -2,134 +2,132 @@ require('~/extensions/array');
require('~/filtered_search/filtered_search_token_keys');
require('~/filtered_search/filtered_search_tokenizer');
(() => {
describe('Filtered Search Tokenizer', () => {
describe('processTokens', () => {
it('returns for input containing only search value', () => {
const results = gl.FilteredSearchTokenizer.processTokens('searchTerm');
expect(results.searchToken).toBe('searchTerm');
expect(results.tokens.length).toBe(0);
expect(results.lastToken).toBe(results.searchToken);
});
it('returns for input containing only tokens', () => {
const results = gl.FilteredSearchTokenizer
.processTokens('author:@root label:~"Very Important" milestone:%v1.0 assignee:none');
expect(results.searchToken).toBe('');
expect(results.tokens.length).toBe(4);
expect(results.tokens[3]).toBe(results.lastToken);
expect(results.tokens[0].key).toBe('author');
expect(results.tokens[0].value).toBe('root');
expect(results.tokens[0].symbol).toBe('@');
expect(results.tokens[1].key).toBe('label');
expect(results.tokens[1].value).toBe('"Very Important"');
expect(results.tokens[1].symbol).toBe('~');
expect(results.tokens[2].key).toBe('milestone');
expect(results.tokens[2].value).toBe('v1.0');
expect(results.tokens[2].symbol).toBe('%');
expect(results.tokens[3].key).toBe('assignee');
expect(results.tokens[3].value).toBe('none');
expect(results.tokens[3].symbol).toBe('');
});
it('returns for input starting with search value and ending with tokens', () => {
const results = gl.FilteredSearchTokenizer
.processTokens('searchTerm anotherSearchTerm milestone:none');
expect(results.searchToken).toBe('searchTerm anotherSearchTerm');
expect(results.tokens.length).toBe(1);
expect(results.tokens[0]).toBe(results.lastToken);
expect(results.tokens[0].key).toBe('milestone');
expect(results.tokens[0].value).toBe('none');
expect(results.tokens[0].symbol).toBe('');
});
it('returns for input starting with tokens and ending with search value', () => {
const results = gl.FilteredSearchTokenizer
.processTokens('assignee:@user searchTerm');
expect(results.searchToken).toBe('searchTerm');
expect(results.tokens.length).toBe(1);
expect(results.tokens[0].key).toBe('assignee');
expect(results.tokens[0].value).toBe('user');
expect(results.tokens[0].symbol).toBe('@');
expect(results.lastToken).toBe(results.searchToken);
});
it('returns for input containing search value wrapped between tokens', () => {
const results = gl.FilteredSearchTokenizer
.processTokens('author:@root label:~"Won\'t fix" searchTerm anotherSearchTerm milestone:none');
expect(results.searchToken).toBe('searchTerm anotherSearchTerm');
expect(results.tokens.length).toBe(3);
expect(results.tokens[2]).toBe(results.lastToken);
expect(results.tokens[0].key).toBe('author');
expect(results.tokens[0].value).toBe('root');
expect(results.tokens[0].symbol).toBe('@');
expect(results.tokens[1].key).toBe('label');
expect(results.tokens[1].value).toBe('"Won\'t fix"');
expect(results.tokens[1].symbol).toBe('~');
expect(results.tokens[2].key).toBe('milestone');
expect(results.tokens[2].value).toBe('none');
expect(results.tokens[2].symbol).toBe('');
});
it('returns for input containing search value in between tokens', () => {
const results = gl.FilteredSearchTokenizer
.processTokens('author:@root searchTerm assignee:none anotherSearchTerm label:~Doing');
expect(results.searchToken).toBe('searchTerm anotherSearchTerm');
expect(results.tokens.length).toBe(3);
expect(results.tokens[2]).toBe(results.lastToken);
expect(results.tokens[0].key).toBe('author');
expect(results.tokens[0].value).toBe('root');
expect(results.tokens[0].symbol).toBe('@');
expect(results.tokens[1].key).toBe('assignee');
expect(results.tokens[1].value).toBe('none');
expect(results.tokens[1].symbol).toBe('');
expect(results.tokens[2].key).toBe('label');
expect(results.tokens[2].value).toBe('Doing');
expect(results.tokens[2].symbol).toBe('~');
});
it('returns search value for invalid tokens', () => {
const results = gl.FilteredSearchTokenizer.processTokens('fake:token');
expect(results.lastToken).toBe('fake:token');
expect(results.searchToken).toBe('fake:token');
expect(results.tokens.length).toEqual(0);
});
it('returns search value and token for mix of valid and invalid tokens', () => {
const results = gl.FilteredSearchTokenizer.processTokens('label:real fake:token');
expect(results.tokens.length).toEqual(1);
expect(results.tokens[0].key).toBe('label');
expect(results.tokens[0].value).toBe('real');
expect(results.tokens[0].symbol).toBe('');
expect(results.lastToken).toBe('fake:token');
expect(results.searchToken).toBe('fake:token');
});
it('returns search value for invalid symbols', () => {
const results = gl.FilteredSearchTokenizer.processTokens('std::includes');
expect(results.lastToken).toBe('std::includes');
expect(results.searchToken).toBe('std::includes');
});
it('removes duplicated values', () => {
const results = gl.FilteredSearchTokenizer.processTokens('label:~foo label:~foo');
expect(results.tokens.length).toBe(1);
expect(results.tokens[0].key).toBe('label');
expect(results.tokens[0].value).toBe('foo');
expect(results.tokens[0].symbol).toBe('~');
});
describe('Filtered Search Tokenizer', () => {
describe('processTokens', () => {
it('returns for input containing only search value', () => {
const results = gl.FilteredSearchTokenizer.processTokens('searchTerm');
expect(results.searchToken).toBe('searchTerm');
expect(results.tokens.length).toBe(0);
expect(results.lastToken).toBe(results.searchToken);
});
it('returns for input containing only tokens', () => {
const results = gl.FilteredSearchTokenizer
.processTokens('author:@root label:~"Very Important" milestone:%v1.0 assignee:none');
expect(results.searchToken).toBe('');
expect(results.tokens.length).toBe(4);
expect(results.tokens[3]).toBe(results.lastToken);
expect(results.tokens[0].key).toBe('author');
expect(results.tokens[0].value).toBe('root');
expect(results.tokens[0].symbol).toBe('@');
expect(results.tokens[1].key).toBe('label');
expect(results.tokens[1].value).toBe('"Very Important"');
expect(results.tokens[1].symbol).toBe('~');
expect(results.tokens[2].key).toBe('milestone');
expect(results.tokens[2].value).toBe('v1.0');
expect(results.tokens[2].symbol).toBe('%');
expect(results.tokens[3].key).toBe('assignee');
expect(results.tokens[3].value).toBe('none');
expect(results.tokens[3].symbol).toBe('');
});
it('returns for input starting with search value and ending with tokens', () => {
const results = gl.FilteredSearchTokenizer
.processTokens('searchTerm anotherSearchTerm milestone:none');
expect(results.searchToken).toBe('searchTerm anotherSearchTerm');
expect(results.tokens.length).toBe(1);
expect(results.tokens[0]).toBe(results.lastToken);
expect(results.tokens[0].key).toBe('milestone');
expect(results.tokens[0].value).toBe('none');
expect(results.tokens[0].symbol).toBe('');
});
it('returns for input starting with tokens and ending with search value', () => {
const results = gl.FilteredSearchTokenizer
.processTokens('assignee:@user searchTerm');
expect(results.searchToken).toBe('searchTerm');
expect(results.tokens.length).toBe(1);
expect(results.tokens[0].key).toBe('assignee');
expect(results.tokens[0].value).toBe('user');
expect(results.tokens[0].symbol).toBe('@');
expect(results.lastToken).toBe(results.searchToken);
});
it('returns for input containing search value wrapped between tokens', () => {
const results = gl.FilteredSearchTokenizer
.processTokens('author:@root label:~"Won\'t fix" searchTerm anotherSearchTerm milestone:none');
expect(results.searchToken).toBe('searchTerm anotherSearchTerm');
expect(results.tokens.length).toBe(3);
expect(results.tokens[2]).toBe(results.lastToken);
expect(results.tokens[0].key).toBe('author');
expect(results.tokens[0].value).toBe('root');
expect(results.tokens[0].symbol).toBe('@');
expect(results.tokens[1].key).toBe('label');
expect(results.tokens[1].value).toBe('"Won\'t fix"');
expect(results.tokens[1].symbol).toBe('~');
expect(results.tokens[2].key).toBe('milestone');
expect(results.tokens[2].value).toBe('none');
expect(results.tokens[2].symbol).toBe('');
});
it('returns for input containing search value in between tokens', () => {
const results = gl.FilteredSearchTokenizer
.processTokens('author:@root searchTerm assignee:none anotherSearchTerm label:~Doing');
expect(results.searchToken).toBe('searchTerm anotherSearchTerm');
expect(results.tokens.length).toBe(3);
expect(results.tokens[2]).toBe(results.lastToken);
expect(results.tokens[0].key).toBe('author');
expect(results.tokens[0].value).toBe('root');
expect(results.tokens[0].symbol).toBe('@');
expect(results.tokens[1].key).toBe('assignee');
expect(results.tokens[1].value).toBe('none');
expect(results.tokens[1].symbol).toBe('');
expect(results.tokens[2].key).toBe('label');
expect(results.tokens[2].value).toBe('Doing');
expect(results.tokens[2].symbol).toBe('~');
});
it('returns search value for invalid tokens', () => {
const results = gl.FilteredSearchTokenizer.processTokens('fake:token');
expect(results.lastToken).toBe('fake:token');
expect(results.searchToken).toBe('fake:token');
expect(results.tokens.length).toEqual(0);
});
it('returns search value and token for mix of valid and invalid tokens', () => {
const results = gl.FilteredSearchTokenizer.processTokens('label:real fake:token');
expect(results.tokens.length).toEqual(1);
expect(results.tokens[0].key).toBe('label');
expect(results.tokens[0].value).toBe('real');
expect(results.tokens[0].symbol).toBe('');
expect(results.lastToken).toBe('fake:token');
expect(results.searchToken).toBe('fake:token');
});
it('returns search value for invalid symbols', () => {
const results = gl.FilteredSearchTokenizer.processTokens('std::includes');
expect(results.lastToken).toBe('std::includes');
expect(results.searchToken).toBe('std::includes');
});
it('removes duplicated values', () => {
const results = gl.FilteredSearchTokenizer.processTokens('label:~foo label:~foo');
expect(results.tokens.length).toBe(1);
expect(results.tokens[0].key).toBe('label');
expect(results.tokens[0].value).toBe('foo');
expect(results.tokens[0].symbol).toBe('~');
});
});
})();
});
......@@ -315,7 +315,7 @@ require('~/lib/utils/common_utils');
describe('gl.utils.setFavicon', () => {
it('should set page favicon to provided favicon', () => {
const faviconName = 'custom_favicon';
const faviconPath = '//custom_favicon';
const fakeLink = {
setAttribute() {},
};
......@@ -323,9 +323,9 @@ require('~/lib/utils/common_utils');
spyOn(window.document, 'getElementById').and.callFake(() => fakeLink);
spyOn(fakeLink, 'setAttribute').and.callFake((attr, val) => {
expect(attr).toEqual('href');
expect(val.indexOf('/assets/custom_favicon.ico') > -1).toBe(true);
expect(val.indexOf(faviconPath) > -1).toBe(true);
});
gl.utils.setFavicon(faviconName);
gl.utils.setFavicon(faviconPath);
});
});
......@@ -347,13 +347,12 @@ require('~/lib/utils/common_utils');
describe('gl.utils.setCiStatusFavicon', () => {
it('should set page favicon to CI status favicon based on provided status', () => {
const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/builds/1/status.json`;
const FAVICON_PATH = 'ci_favicons/';
const FAVICON = 'icon_status_success';
const FAVICON_PATH = '//icon_status_success';
const spySetFavicon = spyOn(gl.utils, 'setFavicon').and.stub();
const spyResetFavicon = spyOn(gl.utils, 'resetFavicon').and.stub();
spyOn($, 'ajax').and.callFake(function (options) {
options.success({ icon: FAVICON });
expect(spySetFavicon).toHaveBeenCalledWith(FAVICON_PATH + FAVICON);
options.success({ favicon: FAVICON_PATH });
expect(spySetFavicon).toHaveBeenCalledWith(FAVICON_PATH);
options.success();
expect(spyResetFavicon).toHaveBeenCalled();
options.error();
......
......@@ -72,5 +72,157 @@ require('~/lib/utils/text_utility');
expect(this.autoSizeSpy).toHaveBeenTriggered();
});
});
describe('renderNote', () => {
let notes;
let note;
let $notesList;
beforeEach(() => {
note = {
discussion_html: null,
valid: true,
html: '<div></div>',
};
$notesList = jasmine.createSpyObj('$notesList', ['find']);
notes = jasmine.createSpyObj('notes', [
'refresh',
'isNewNote',
'collapseLongCommitList',
'updateNotesCount',
]);
notes.taskList = jasmine.createSpyObj('tasklist', ['init']);
notes.note_ids = [];
spyOn(window, '$').and.returnValue($notesList);
spyOn(gl.utils, 'localTimeAgo');
spyOn(Notes, 'animateAppendNote');
notes.isNewNote.and.returnValue(true);
Notes.prototype.renderNote.call(notes, note);
});
it('should query for the notes list', () => {
expect(window.$).toHaveBeenCalledWith('ul.main-notes-list');
});
it('should call .animateAppendNote', () => {
expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.html, $notesList);
});
});
describe('renderDiscussionNote', () => {
let discussionContainer;
let note;
let notes;
let $form;
let row;
beforeEach(() => {
note = {
html: '<li></li>',
discussion_html: '<div></div>',
discussion_id: 1,
discussion_resolvable: false,
diff_discussion_html: false,
};
$form = jasmine.createSpyObj('$form', ['closest', 'find']);
row = jasmine.createSpyObj('row', ['prevAll', 'first', 'find']);
notes = jasmine.createSpyObj('notes', [
'isNewNote',
'isParallelView',
'updateNotesCount',
]);
notes.note_ids = [];
spyOn(gl.utils, 'localTimeAgo');
spyOn(Notes, 'animateAppendNote');
notes.isNewNote.and.returnValue(true);
notes.isParallelView.and.returnValue(false);
row.prevAll.and.returnValue(row);
row.first.and.returnValue(row);
row.find.and.returnValue(row);
});
describe('Discussion root note', () => {
let $notesList;
let body;
beforeEach(() => {
body = jasmine.createSpyObj('body', ['attr']);
discussionContainer = { length: 0 };
spyOn(window, '$').and.returnValues(discussionContainer, body, $notesList);
$form.closest.and.returnValues(row, $form);
$form.find.and.returnValues(discussionContainer);
body.attr.and.returnValue('');
Notes.prototype.renderDiscussionNote.call(notes, note, $form);
});
it('should query for the notes list', () => {
expect(window.$.calls.argsFor(2)).toEqual(['ul.main-notes-list']);
});
it('should call Notes.animateAppendNote', () => {
expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.discussion_html, $notesList);
});
});
describe('Discussion sub note', () => {
beforeEach(() => {
discussionContainer = { length: 1 };
spyOn(window, '$').and.returnValues(discussionContainer);
$form.closest.and.returnValues(row);
Notes.prototype.renderDiscussionNote.call(notes, note, $form);
});
it('should query foor the discussion container', () => {
expect(window.$).toHaveBeenCalledWith(`.notes[data-discussion-id="${note.discussion_id}"]`);
});
it('should call Notes.animateAppendNote', () => {
expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.html, discussionContainer);
});
});
});
describe('animateAppendNote', () => {
let noteHTML;
let $note;
let $notesList;
beforeEach(() => {
noteHTML = '<div></div>';
$note = jasmine.createSpyObj('$note', ['addClass', 'renderGFM', 'removeClass']);
$notesList = jasmine.createSpyObj('$notesList', ['append']);
spyOn(window, '$').and.returnValue($note);
spyOn(window, 'setTimeout').and.callThrough();
$note.addClass.and.returnValue($note);
$note.renderGFM.and.returnValue($note);
Notes.animateAppendNote(noteHTML, $notesList);
});
it('should init the note jquery object', () => {
expect(window.$).toHaveBeenCalledWith(noteHTML);
});
it('should call addClass', () => {
expect($note.addClass).toHaveBeenCalledWith('fade-in');
});
it('should call renderGFM', () => {
expect($note.renderGFM).toHaveBeenCalledWith();
});
it('should append note to the notes list', () => {
expect($notesList.append).toHaveBeenCalledWith($note);
});
});
});
}).call(window);
......@@ -14,7 +14,6 @@ describe('UserCallout', function () {
this.userCallout = new UserCallout();
this.closeButton = $('.js-close-callout.close');
this.userCalloutBtn = $('.js-close-callout:not(.close)');
this.userCalloutContainer = $('.user-callout');
});
it('hides when user clicks on the dismiss-icon', (done) => {
......
import Vue from 'vue';
import { SUCCESS_SVG } from '~/ci_status_icons';
import Stage from '~/pipelines/components/stage';
function minify(string) {
return string.replace(/\s/g, '');
}
describe('Pipelines Stage', () => {
describe('data', () => {
let stageReturnValue;
beforeEach(() => {
stageReturnValue = Stage.data();
});
it('should return object with .builds and .spinner', () => {
expect(stageReturnValue).toEqual({
builds: '',
spinner: '<span class="fa fa-spinner fa-spin"></span>',
});
});
});
describe('computed', () => {
describe('svgHTML', function () {
let stage;
let svgHTML;
beforeEach(() => {
stage = { stage: { status: { icon: 'icon_status_success' } } };
svgHTML = Stage.computed.svgHTML.call(stage);
});
it("should return the correct icon for the stage's status", () => {
expect(svgHTML).toBe(SUCCESS_SVG);
});
});
});
describe('when mounted', () => {
let StageComponent;
let renderedComponent;
let stage;
beforeEach(() => {
stage = { status: { icon: 'icon_status_success' } };
StageComponent = Vue.extend(Stage);
renderedComponent = new StageComponent({
propsData: {
stage,
},
}).$mount();
});
it('should render the correct status svg', () => {
const minifiedComponent = minify(renderedComponent.$el.outerHTML);
const expectedSVG = minify(SUCCESS_SVG);
expect(minifiedComponent).toContain(expectedSVG);
});
});
});
......@@ -5,6 +5,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do
include FilterSpecHelper
let(:user) { create(:user) }
let(:context) { { current_user: user, issuable_state_filter_enabled: true } }
def create_link(text, data)
link_to(text, '', class: 'gfm has-tooltip', data: data)
......@@ -20,7 +21,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do
it 'ignores non-issuable links' do
project = create(:empty_project, :public)
link = create_link('text', project: project, reference_type: 'issue')
doc = filter(link, current_user: user)
doc = filter(link, context)
expect(doc.css('a').last.text).to eq('text')
end
......@@ -28,89 +29,142 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do
it 'ignores issuable links with empty content' do
issue = create(:issue, :closed)
link = create_link('', issue: issue.id, reference_type: 'issue')
doc = filter(link, current_user: user)
doc = filter(link, context)
expect(doc.css('a').last.text).to eq('')
end
it 'adds text with standard formatting' do
it 'ignores issuable links with custom anchor' do
issue = create(:issue, :closed)
link = create_link('something', issue: issue.id, reference_type: 'issue')
doc = filter(link, context)
expect(doc.css('a').last.text).to eq('something')
end
it 'ignores issuable links to specific comments' do
issue = create(:issue, :closed)
link = create_link("#{issue.to_reference} (comment 1)", issue: issue.id, reference_type: 'issue')
doc = filter(link, context)
expect(doc.css('a').last.text).to eq("#{issue.to_reference} (comment 1)")
end
it 'ignores merge request links to diffs tab' do
merge_request = create(:merge_request, :closed)
link = create_link(
'something <strong>else</strong>'.html_safe,
issue: issue.id,
reference_type: 'issue'
"#{merge_request.to_reference} (diffs)",
merge_request: merge_request.id,
reference_type: 'merge_request'
)
doc = filter(link, current_user: user)
doc = filter(link, context)
expect(doc.css('a').last.inner_html).
to eq('something <strong>else</strong> [closed]')
expect(doc.css('a').last.text).to eq("#{merge_request.to_reference} (diffs)")
end
it 'handles cross project references' do
issue = create(:issue, :closed)
project = create(:empty_project)
link = create_link(issue.to_reference(project), issue: issue.id, reference_type: 'issue')
doc = filter(link, context.merge(project: project))
expect(doc.css('a').last.text).to eq("#{issue.to_reference(project)} (closed)")
end
it 'does not append state when filter is not enabled' do
issue = create(:issue, :closed)
link = create_link('text', issue: issue.id, reference_type: 'issue')
context = { current_user: user }
doc = filter(link, context)
expect(doc.css('a').last.text).to eq('text')
end
context 'for issue references' do
it 'ignores open issue references' do
issue = create(:issue)
link = create_link('text', issue: issue.id, reference_type: 'issue')
doc = filter(link, current_user: user)
link = create_link(issue.to_reference, issue: issue.id, reference_type: 'issue')
doc = filter(link, context)
expect(doc.css('a').last.text).to eq('text')
expect(doc.css('a').last.text).to eq(issue.to_reference)
end
it 'ignores reopened issue references' do
reopened_issue = create(:issue, :reopened)
link = create_link('text', issue: reopened_issue.id, reference_type: 'issue')
doc = filter(link, current_user: user)
issue = create(:issue, :reopened)
link = create_link(issue.to_reference, issue: issue.id, reference_type: 'issue')
doc = filter(link, context)
expect(doc.css('a').last.text).to eq('text')
expect(doc.css('a').last.text).to eq(issue.to_reference)
end
it 'appends [closed] to closed issue references' do
closed_issue = create(:issue, :closed)
link = create_link('text', issue: closed_issue.id, reference_type: 'issue')
doc = filter(link, current_user: user)
it 'appends state to closed issue references' do
issue = create(:issue, :closed)
link = create_link(issue.to_reference, issue: issue.id, reference_type: 'issue')
doc = filter(link, context)
expect(doc.css('a').last.text).to eq('text [closed]')
expect(doc.css('a').last.text).to eq("#{issue.to_reference} (closed)")
end
end
context 'for merge request references' do
it 'ignores open merge request references' do
mr = create(:merge_request)
link = create_link('text', merge_request: mr.id, reference_type: 'merge_request')
doc = filter(link, current_user: user)
expect(doc.css('a').last.text).to eq('text')
merge_request = create(:merge_request)
link = create_link(
merge_request.to_reference,
merge_request: merge_request.id,
reference_type: 'merge_request'
)
doc = filter(link, context)
expect(doc.css('a').last.text).to eq(merge_request.to_reference)
end
it 'ignores reopened merge request references' do
mr = create(:merge_request, :reopened)
link = create_link('text', merge_request: mr.id, reference_type: 'merge_request')
doc = filter(link, current_user: user)
expect(doc.css('a').last.text).to eq('text')
merge_request = create(:merge_request, :reopened)
link = create_link(
merge_request.to_reference,
merge_request: merge_request.id,
reference_type: 'merge_request'
)
doc = filter(link, context)
expect(doc.css('a').last.text).to eq(merge_request.to_reference)
end
it 'ignores locked merge request references' do
mr = create(:merge_request, :locked)
link = create_link('text', merge_request: mr.id, reference_type: 'merge_request')
doc = filter(link, current_user: user)
expect(doc.css('a').last.text).to eq('text')
merge_request = create(:merge_request, :locked)
link = create_link(
merge_request.to_reference,
merge_request: merge_request.id,
reference_type: 'merge_request'
)
doc = filter(link, context)
expect(doc.css('a').last.text).to eq(merge_request.to_reference)
end
it 'appends [closed] to closed merge request references' do
mr = create(:merge_request, :closed)
link = create_link('text', merge_request: mr.id, reference_type: 'merge_request')
doc = filter(link, current_user: user)
it 'appends state to closed merge request references' do
merge_request = create(:merge_request, :closed)
link = create_link(
merge_request.to_reference,
merge_request: merge_request.id,
reference_type: 'merge_request'
)
doc = filter(link, context)
expect(doc.css('a').last.text).to eq('text [closed]')
expect(doc.css('a').last.text).to eq("#{merge_request.to_reference} (closed)")
end
it 'appends [merged] to merged merge request references' do
mr = create(:merge_request, :merged)
link = create_link('text', merge_request: mr.id, reference_type: 'merge_request')
doc = filter(link, current_user: user)
it 'appends state to merged merge request references' do
merge_request = create(:merge_request, :merged)
link = create_link(
merge_request.to_reference,
merge_request: merge_request.id,
reference_type: 'merge_request'
)
doc = filter(link, context)
expect(doc.css('a').last.text).to eq('text [merged]')
expect(doc.css('a').last.text).to eq("#{merge_request.to_reference} (merged)")
end
end
end
......@@ -5,7 +5,7 @@ describe Banzai::Filter::PlantumlFilter, lib: true do
it 'should replace plantuml pre tag with img tag' do
stub_application_setting(plantuml_enabled: true, plantuml_url: "http://localhost:8080")
input = '<pre class="plantuml"><code>Bob -> Sara : Hello</code><pre>'
input = '<pre><code lang="plantuml">Bob -> Sara : Hello</code></pre>'
output = '<div class="imageblock"><div class="content"><img class="plantuml" src="http://localhost:8080/png/U9npoazIqBLJ24uiIbImKl18pSd91m0rkGMq"></div></div>'
doc = filter(input)
......@@ -14,8 +14,8 @@ describe Banzai::Filter::PlantumlFilter, lib: true do
it 'should not replace plantuml pre tag with img tag if disabled' do
stub_application_setting(plantuml_enabled: false)
input = '<pre class="plantuml"><code>Bob -> Sara : Hello</code><pre>'
output = '<pre class="plantuml"><code>Bob -&gt; Sara : Hello</code><pre></pre></pre>'
input = '<pre><code lang="plantuml">Bob -> Sara : Hello</code></pre>'
output = '<pre><code lang="plantuml">Bob -&gt; Sara : Hello</code></pre>'
doc = filter(input)
expect(doc.to_s).to eq output
......@@ -23,7 +23,7 @@ describe Banzai::Filter::PlantumlFilter, lib: true do
it 'should not replace plantuml pre tag with img tag if url is invalid' do
stub_application_setting(plantuml_enabled: true, plantuml_url: "invalid")
input = '<pre class="plantuml"><code>Bob -> Sara : Hello</code><pre>'
input = '<pre><code lang="plantuml">Bob -> Sara : Hello</code></pre>'
output = '<div class="listingblock"><div class="content"><pre class="plantuml plantuml-error"> PlantUML Error: cannot connect to PlantUML server at "invalid"</pre></div></div>'
doc = filter(input)
......
......@@ -189,15 +189,10 @@ describe ContainerRegistry::Path do
end
context 'when project exists' do
let(:group) { create(:group, path: 'some_group') }
let(:project) do
create(:empty_project, group: group, name: 'some_project')
end
let(:group) { create(:group, path: 'Some_Group') }
before do
allow(path).to receive(:repository_project)
.and_return(project)
create(:empty_project, group: group, name: 'some_project')
end
context 'when project path equal repository path' do
......@@ -225,4 +220,27 @@ describe ContainerRegistry::Path do
end
end
end
describe '#project_path' do
context 'when project does not exist' do
let(:path) { 'some/name' }
it 'returns nil' do
expect(subject.project_path).to be_nil
end
end
context 'when project with uppercase characters in path exists' do
let(:path) { 'somegroup/myproject/my/image' }
let(:group) { create(:group, path: 'SomeGroup') }
before do
create(:empty_project, group: group, name: 'MyProject')
end
it 'returns downcased project path' do
expect(subject.project_path).to eq 'somegroup/myproject'
end
end
end
end
......@@ -6,7 +6,7 @@ describe Gitlab::UsageData do
let!(:board) { create(:board, project: project) }
describe '#data' do
subject { described_class.data }
subject { Gitlab::UsageData.data }
it "gathers usage data" do
expect(subject.keys).to match_array(%i(
......@@ -69,8 +69,8 @@ describe Gitlab::UsageData do
end
end
describe '.license_usage_data' do
subject { described_class.license_usage_data }
describe '#license_usage_data' do
subject { Gitlab::UsageData.license_usage_data }
it "gathers license data" do
license = ::License.current
......
require 'spec_helper'
describe ChatNotificationService, models: true do
describe "Associations" do
describe 'Associations' do
before do
allow(subject).to receive(:activated?).and_return(true)
end
it { is_expected.to validate_presence_of :webhook }
end
describe '#can_test?' do
context 'with empty repository' do
it 'returns false' do
subject.project = create(:empty_project, :empty_repo)
expect(subject.can_test?).to be false
end
end
context 'with repository' do
it 'returns true' do
subject.project = create(:project)
expect(subject.can_test?).to be true
end
end
end
end
......@@ -72,6 +72,12 @@ describe API::Users, api: true do
expect(json_response).to be_an Array
expect(json_response.first['username']).to eq(omniauth_user.username)
end
it "returns a 403 when non-admin user searches by external UID" do
get api("/users?extern_uid=#{omniauth_user.identities.first.extern_uid}&provider=#{omniauth_user.identities.first.provider}", user)
expect(response).to have_http_status(403)
end
end
context "when admin" do
......@@ -100,6 +106,27 @@ describe API::Users, api: true do
expect(json_response).to be_an Array
expect(json_response).to all(include('external' => true))
end
it "returns one user by external UID" do
get api("/users?extern_uid=#{omniauth_user.identities.first.extern_uid}&provider=#{omniauth_user.identities.first.provider}", admin)
expect(response).to have_http_status(200)
expect(json_response).to be_an Array
expect(json_response.size).to eq(1)
expect(json_response.first['username']).to eq(omniauth_user.username)
end
it "returns 400 error if provider with no extern_uid" do
get api("/users?extern_uid=#{omniauth_user.identities.first.extern_uid}", admin)
expect(response).to have_http_status(400)
end
it "returns 400 error if provider with no extern_uid" do
get api("/users?provider=#{omniauth_user.identities.first.provider}", admin)
expect(response).to have_http_status(400)
end
end
context "when authenticated and ldap is enabled" do
......@@ -892,7 +919,7 @@ describe API::Users, api: true do
delete api("/user/keys/#{key.id}", user)
expect(response).to have_http_status(204)
end.to change{user.keys.count}.by(-1)
end.to change { user.keys.count}.by(-1)
end
it "returns 404 if key ID not found" do
......@@ -1001,7 +1028,7 @@ describe API::Users, api: true do
delete api("/user/emails/#{email.id}", user)
expect(response).to have_http_status(204)
end.to change{user.emails.count}.by(-1)
end.to change { user.emails.count}.by(-1)
end
it "returns 404 if email ID not found" do
......
......@@ -3,6 +3,7 @@ require "spec_helper"
describe 'Git HTTP requests', lib: true do
include GitHttpHelpers
include WorkhorseHelpers
include UserActivitiesHelpers
it "gives WWW-Authenticate hints" do
clone_get('doesnt/exist.git')
......@@ -383,6 +384,14 @@ describe 'Git HTTP requests', lib: true do
expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
end
end
it 'updates the user last activity', :redis do
expect(user_activity(user)).to be_nil
download(path, env) do |response|
expect(user_activity(user)).to be_present
end
end
end
context "when an oauth token is provided" do
......
......@@ -38,7 +38,7 @@ describe BuildSerializer do
expect(subject[:text]).to eq(status.text)
expect(subject[:label]).to eq(status.label)
expect(subject[:icon]).to eq(status.icon)
expect(subject[:favicon]).to eq(status.favicon)
expect(subject[:favicon]).to eq("/assets/ci_favicons/#{status.favicon}.ico")
end
end
end
......
......@@ -144,7 +144,7 @@ describe PipelineSerializer do
expect(subject[:text]).to eq(status.text)
expect(subject[:label]).to eq(status.label)
expect(subject[:icon]).to eq(status.icon)
expect(subject[:favicon]).to eq(status.favicon)
expect(subject[:favicon]).to eq("/assets/ci_favicons/#{status.favicon}.ico")
end
end
end
......
......@@ -42,6 +42,19 @@ describe DeleteMergedBranchesService, services: true do
expect { described_class.new(project, user).execute }.to raise_error(Gitlab::Access::AccessDeniedError)
end
end
context 'open merge requests' do
it 'does not delete branches from open merge requests' do
fork_link = create(:forked_project_link, forked_from_project: project)
create(:merge_request, :reopened, source_project: project, target_project: project, source_branch: 'branch-merged', target_branch: 'master')
create(:merge_request, :opened, source_project: fork_link.forked_to_project, target_project: project, target_branch: 'improve/awesome', source_branch: 'master')
service.execute
expect(project.repository.branch_names).to include('branch-merged')
expect(project.repository.branch_names).to include('improve/awesome')
end
end
end
context '#async_execute' do
......
......@@ -7,6 +7,7 @@ describe Groups::DestroyService, services: true do
let!(:group) { create(:group) }
let!(:nested_group) { create(:group, parent: group) }
let!(:project) { create(:empty_project, namespace: group) }
let!(:notification_setting) { create(:notification_setting, source: group)}
let!(:gitlab_shell) { Gitlab::Shell.new }
let!(:remove_path) { group.path + "+#{group.id}+deleted" }
......@@ -23,6 +24,7 @@ describe Groups::DestroyService, services: true do
it { expect(Group.unscoped.all).not_to include(group) }
it { expect(Group.unscoped.all).not_to include(nested_group) }
it { expect(Project.unscoped.all).not_to include(project) }
it { expect(NotificationSetting.unscoped.all).not_to include(notification_setting) }
end
context 'file system' do
......
......@@ -73,9 +73,15 @@ shared_examples 'discussion comments' do |resource_name|
expect(page).not_to have_selector menu_selector
end
it 'clicking the ul padding should not change the text' do
it 'clicking the ul padding or divider should not change the text' do
find(menu_selector).trigger 'click'
expect(page).to have_selector menu_selector
expect(find(dropdown_selector)).to have_content 'Comment'
find("#{menu_selector} .divider").trigger 'click'
expect(page).to have_selector menu_selector
expect(find(dropdown_selector)).to have_content 'Comment'
end
......
module MobileHelpers
def resize_screen_xs
resize_window(767, 768)
end
def resize_screen_sm
resize_window(900, 768)
end
......
require 'spec_helper'
describe 'layouts/nav/_project' do
describe 'container registry tab' do
before do
stub_container_registry_config(enabled: true)
assign(:project, create(:project))
allow(view).to receive(:current_ref).and_return('master')
allow(view).to receive(:can?).and_return(true)
allow(controller).to receive(:controller_name)
.and_return('repositories')
allow(controller).to receive(:controller_path)
.and_return('projects/registry/repositories')
end
it 'has both Registry and Repository tabs' do
render
expect(rendered).to have_text 'Repository'
expect(rendered).to have_text 'Registry'
end
it 'highlights only one tab' do
render
expect(rendered).to have_css('.active', count: 1)
end
it 'highlights container registry tab only' do
render
expect(rendered).to have_css('.active', text: 'Registry')
end
end
end
......@@ -6,8 +6,8 @@ describe GitlabUsagePingWorker do
it "sends POST request" do
stub_application_setting(usage_ping_enabled: true)
stub_request(:post, "https://version.gitlab.com/usage_data")
.to_return(status: 200, body: '', headers: {})
stub_request(:post, "https://version.gitlab.com/usage_data").
to_return(status: 200, body: '', headers: {})
expect(Gitlab::UsageData).to receive(:to_json).with({ force_refresh: true }).and_call_original
expect(subject).to receive(:try_obtain_lease).and_return(true)
......
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