diff options
author | Clement Ho <ClemMakesApps@gmail.com> | 2017-11-13 13:21:16 -0600 |
---|---|---|
committer | Clement Ho <ClemMakesApps@gmail.com> | 2017-11-13 13:21:16 -0600 |
commit | 421acc9a539725e9dd7f046c19b0b338f1257ce5 (patch) | |
tree | 11abe24ca4f272b25c00723ca1acb02dfc7126c2 | |
parent | 74b87f02db2ebda0b2b16a60dd6759fe6e8de95a (diff) | |
parent | 2932f532c35a1fff58486920a03227862594f54d (diff) | |
download | gitlab-ce-421acc9a539725e9dd7f046c19b0b338f1257ce5.tar.gz |
Merge branch 'master' into backport-add-epic-sidebar
98 files changed, 2787 insertions, 479 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5710effc39d..65f2bc7045f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -416,12 +416,8 @@ ee_compat_check: - /^[\d-]+-stable(-ee)?/ - branches@gitlab-org/gitlab-ee - branches@gitlab/gitlab-ee - allow_failure: yes + allow_failure: no retry: 0 - cache: - key: "ee_compat_check_repo" - paths: - - ee_compat_check/ee-repo/ artifacts: name: "${CI_JOB_NAME}_${CI_COMIT_REF_NAME}_${CI_COMMIT_SHA}" when: on_failure diff --git a/CHANGELOG.md b/CHANGELOG.md index 2482f0124bd..4294ccaf9b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 10.1.3 (2017-11-10) + +- [SECURITY] Prevent OAuth phishing attack by presenting detailed wording about app to user during authorization. +- [FIXED] Fix cancel button not working while uploading on the new issue page. !15137 +- [FIXED] Fix webhooks recent deliveries. !15146 (Alexander Randa (@randaalex)) +- [FIXED] Fix issues with forked projects of which the source was deleted. !15150 +- [FIXED] Fix GPG signature popup info in Safari and Firefox. !15228 +- [FIXED] Make sure group and project creation is blocked for new users that are external by default. +- [FIXED] Fix arguments Import/Export error importing project merge requests. +- [FIXED] Fix diff parser so it tolerates to diff special markers in the content. +- [FIXED] Fix a migration that adds merge_requests_ff_only_enabled column to MR table. +- [FIXED] Render 404 when polling commit notes without having permissions. +- [FIXED] Show error message when fast-forward merge is not possible. +- [FIXED] Avoid regenerating the ref path for the environment. +- [PERFORMANCE] Remove Filesystem check metrics that use too much CPU to handle requests. + ## 10.1.2 (2017-11-08) - [SECURITY] Add X-Content-Type-Options header in API responses to make it more difficult to find other vulnerabilities. diff --git a/app/assets/javascripts/abuse_reports.js b/app/assets/javascripts/abuse_reports.js index 3de192d56eb..d2d3a257c0d 100644 --- a/app/assets/javascripts/abuse_reports.js +++ b/app/assets/javascripts/abuse_reports.js @@ -1,3 +1,5 @@ +import { truncate } from './lib/utils/text_utility'; + const MAX_MESSAGE_LENGTH = 500; const MESSAGE_CELL_SELECTOR = '.abuse-reports .message'; @@ -15,7 +17,7 @@ export default class AbuseReports { if (reportMessage.length > MAX_MESSAGE_LENGTH) { $messageCellElement.data('original-message', reportMessage); $messageCellElement.data('message-truncated', 'true'); - $messageCellElement.text(window.gl.text.truncate(reportMessage, MAX_MESSAGE_LENGTH)); + $messageCellElement.text(truncate(reportMessage, MAX_MESSAGE_LENGTH)); } } diff --git a/app/assets/javascripts/boards/components/modal/footer.js b/app/assets/javascripts/boards/components/modal/footer.js index de9e44cef35..182957113a2 100644 --- a/app/assets/javascripts/boards/components/modal/footer.js +++ b/app/assets/javascripts/boards/components/modal/footer.js @@ -3,6 +3,7 @@ import Vue from 'vue'; import Flash from '../../../flash'; import './lists_dropdown'; +import { pluralize } from '../../../lib/utils/text_utility'; const ModalStore = gl.issueBoards.ModalStore; @@ -21,7 +22,7 @@ gl.issueBoards.ModalFooter = Vue.extend({ submitText() { const count = ModalStore.selectedCount(); - return `Add ${count > 0 ? count : ''} ${gl.text.pluralize('issue', count)}`; + return `Add ${count > 0 ? count : ''} ${pluralize('issue', count)}`; }, }, methods: { diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js index ae6b8902032..9b952ea7b60 100644 --- a/app/assets/javascripts/commits.js +++ b/app/assets/javascripts/commits.js @@ -3,6 +3,8 @@ prefer-template, object-shorthand, prefer-arrow-callback */ /* global Pager */ +import { pluralize } from './lib/utils/text_utility'; + export default (function () { const CommitsList = {}; @@ -86,7 +88,7 @@ export default (function () { // Update commits count in the previous commits header. commitsCount += Number($(processedData).nextUntil('li.js-commit-header').first().find('li.commit').length); - $commitsHeadersLast.find('span.commits-count').text(`${commitsCount} ${gl.text.pluralize('commit', commitsCount)}`); + $commitsHeadersLast.find('span.commits-count').text(`${commitsCount} ${pluralize('commit', commitsCount)}`); } gl.utils.localTimeAgo($processedData.find('.js-timeago')); diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/create_label.js index 3bed0678350..9a4c9bfcc80 100644 --- a/app/assets/javascripts/create_label.js +++ b/app/assets/javascripts/create_label.js @@ -1,5 +1,6 @@ /* eslint-disable func-names, prefer-arrow-callback */ import Api from './api'; +import { humanize } from './lib/utils/text_utility'; export default class CreateLabelDropdown { constructor($el, namespacePath, projectPath) { @@ -107,7 +108,7 @@ export default class CreateLabelDropdown { errors = label.message; } else { errors = Object.keys(label.message).map(key => - `${gl.text.humanize(key)} ${label.message[key].join(', ')}`, + `${humanize(key)} ${label.message[key].join(', ')}`, ).join('<br/>'); } diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js index 8bf9ae17de0..a8cd8c20f8f 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js @@ -1,7 +1,7 @@ /* eslint-disable no-param-reassign */ import { __ } from '../locale'; -import '../lib/utils/text_utility'; +import { dasherize } from '../lib/utils/text_utility'; import DEFAULT_EVENT_OBJECTS from './default_event_objects'; const EMPTY_STAGE_TEXTS = { @@ -36,7 +36,7 @@ export default { }); newData.stages.forEach((item) => { - const stageSlug = gl.text.dasherize(item.name.toLowerCase()); + const stageSlug = dasherize(item.name.toLowerCase()); item.active = false; item.isUserAllowed = data.permissions[stageSlug]; item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug]; diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index fc0308b81ba..9d25f806c0d 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -2,7 +2,7 @@ import Timeago from 'timeago.js'; import _ from 'underscore'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; -import '../../lib/utils/text_utility'; +import { humanize } from '../../lib/utils/text_utility'; import ActionsComponent from './environment_actions.vue'; import ExternalUrlComponent from './environment_external_url.vue'; import StopComponent from './environment_stop.vue'; @@ -139,7 +139,7 @@ export default { if (this.hasManualActions) { return this.model.last_deployment.manual_actions.map((action) => { const parsedAction = { - name: gl.text.humanize(action.name), + name: humanize(action.name), play_path: action.play_path, playable: action.playable, }; diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 5c624b79d45..a642464c920 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -338,7 +338,8 @@ class GfmAutoComplete { let resultantValue = value; if (value && !this.setting.skipSpecialCharacterTest) { const withoutAt = value.substring(1); - if (withoutAt && /[^\w\d]/.test(withoutAt)) { + const regex = value.charAt() === '~' ? /\W|^\d+$/ : /\W/; + if (withoutAt && regex.test(withoutAt)) { resultantValue = `${value.charAt()}"${withoutAt}"`; } } diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index c4202f92443..4e7a6e54f90 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -331,7 +331,7 @@ GitLabDropdown = (function() { if (_this.dropdown.find('.dropdown-toggle-page').length) { selector = ".dropdown-page-one " + selector; } - return $(selector); + return $(selector, this.instance.dropdown); }; })(this), data: (function(_this) { diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index 48cd43d3348..d0f9e6af0f8 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -2,6 +2,7 @@ import GfmAutoComplete from './gfm_auto_complete'; import dropzoneInput from './dropzone_input'; +import textUtils from './lib/utils/text_markdown'; export default class GLForm { constructor(form, enableGFM = false) { @@ -46,7 +47,7 @@ export default class GLForm { } // form and textarea event listeners this.addEventListeners(); - gl.text.init(this.form); + textUtils.init(this.form); // hide discard button this.form.find('.js-note-discard').hide(); this.form.show(); @@ -85,7 +86,7 @@ export default class GLForm { clearEventListeners() { this.textarea.off('focus'); this.textarea.off('blur'); - gl.text.removeListeners(this.form); + textUtils.removeListeners(this.form); } addEventListeners() { diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index acd5730cf3c..7de07e9403d 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -1,6 +1,6 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, no-underscore-dangle, one-var-declaration-per-line, object-shorthand, no-unused-vars, no-new, comma-dangle, consistent-return, quotes, dot-notation, quote-props, prefer-arrow-callback, max-len */ import 'vendor/jquery.waitforimages'; -import '~/lib/utils/text_utility'; +import { addDelimiter } from './lib/utils/text_utility'; import Flash from './flash'; import TaskList from './task_list'; import CreateMergeRequestDropdown from './create_merge_request_dropdown'; @@ -73,7 +73,7 @@ export default class Issue { let numProjectIssues = Number(projectIssuesCounter.first().text().trim().replace(/[^\d]/, '')); numProjectIssues = isClosed ? numProjectIssues - 1 : numProjectIssues + 1; - projectIssuesCounter.text(gl.text.addDelimiter(numProjectIssues)); + projectIssuesCounter.text(addDelimiter(numProjectIssues)); if (this.createMergeRequestDropdown) { if (isClosed) { diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 07899777a1e..5c4926d6ac8 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -172,7 +172,6 @@ export const getSelectedFragment = () => { return documentFragment; }; -// TODO: Update this name, there is a gl.text.insertText function. export const insertText = (target, text) => { // Firefox doesn't support `document.execCommand('insertText', false, text)` on textareas const selectionStart = target.selectionStart; diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 8d0bab55843..52bdc05a58c 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -2,6 +2,7 @@ import timeago from 'timeago.js'; import dateFormat from 'vendor/date.format'; +import { pluralize } from './text_utility'; import { lang, @@ -142,9 +143,9 @@ export function timeIntervalInWords(intervalInSeconds) { let text = ''; if (minutes >= 1) { - text = `${minutes} ${gl.text.pluralize('minute', minutes)} ${seconds} ${gl.text.pluralize('second', seconds)}`; + text = `${minutes} ${pluralize('minute', minutes)} ${seconds} ${pluralize('second', seconds)}`; } else { - text = `${seconds} ${gl.text.pluralize('second', seconds)}`; + text = `${seconds} ${pluralize('second', seconds)}`; } return text; } diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js new file mode 100644 index 00000000000..2dc9cf0cc29 --- /dev/null +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -0,0 +1,153 @@ +/* eslint-disable import/prefer-default-export, func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, quotes, one-var, one-var-declaration-per-line, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, no-empty, max-len, consistent-return, no-unused-vars, no-return-assign, max-len, vars-on-top */ + +const textUtils = {}; + +textUtils.selectedText = function(text, textarea) { + return text.substring(textarea.selectionStart, textarea.selectionEnd); +}; + +textUtils.lineBefore = function(text, textarea) { + var split; + split = text.substring(0, textarea.selectionStart).trim().split('\n'); + return split[split.length - 1]; +}; + +textUtils.lineAfter = function(text, textarea) { + return text.substring(textarea.selectionEnd).trim().split('\n')[0]; +}; + +textUtils.blockTagText = function(text, textArea, blockTag, selected) { + var lineAfter, lineBefore; + lineBefore = this.lineBefore(text, textArea); + lineAfter = this.lineAfter(text, textArea); + if (lineBefore === blockTag && lineAfter === blockTag) { + // To remove the block tag we have to select the line before & after + if (blockTag != null) { + textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1); + textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1); + } + return selected; + } else { + return blockTag + "\n" + selected + "\n" + blockTag; + } +}; + +textUtils.insertText = function(textArea, text, tag, blockTag, selected, wrap) { + var insertText, inserted, selectedSplit, startChar, removedLastNewLine, removedFirstNewLine, currentLineEmpty, lastNewLine; + removedLastNewLine = false; + removedFirstNewLine = false; + currentLineEmpty = false; + + // Remove the first newline + if (selected.indexOf('\n') === 0) { + removedFirstNewLine = true; + selected = selected.replace(/\n+/, ''); + } + + // Remove the last newline + if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) { + removedLastNewLine = true; + selected = selected.replace(/\n$/, ''); + } + + selectedSplit = selected.split('\n'); + + if (!wrap) { + lastNewLine = textArea.value.substr(0, textArea.selectionStart).lastIndexOf('\n'); + + // Check whether the current line is empty or consists only of spaces(=handle as empty) + if (/^\s*$/.test(textArea.value.substring(lastNewLine, textArea.selectionStart))) { + currentLineEmpty = true; + } + } + + startChar = !wrap && !currentLineEmpty && textArea.selectionStart > 0 ? '\n' : ''; + + if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) { + if (blockTag != null && blockTag !== '') { + insertText = this.blockTagText(text, textArea, blockTag, selected); + } else { + insertText = selectedSplit.map(function(val) { + if (val.indexOf(tag) === 0) { + return "" + (val.replace(tag, '')); + } else { + return "" + tag + val; + } + }).join('\n'); + } + } else { + insertText = "" + startChar + tag + selected + (wrap ? tag : ' '); + } + + if (removedFirstNewLine) { + insertText = '\n' + insertText; + } + + if (removedLastNewLine) { + insertText += '\n'; + } + + if (document.queryCommandSupported('insertText')) { + inserted = document.execCommand('insertText', false, insertText); + } + if (!inserted) { + try { + document.execCommand("ms-beginUndoUnit"); + } catch (error) {} + textArea.value = this.replaceRange(text, textArea.selectionStart, textArea.selectionEnd, insertText); + try { + document.execCommand("ms-endUndoUnit"); + } catch (error) {} + } + return this.moveCursor(textArea, tag, wrap, removedLastNewLine); +}; + +textUtils.moveCursor = function(textArea, tag, wrapped, removedLastNewLine) { + var pos; + if (!textArea.setSelectionRange) { + return; + } + if (textArea.selectionStart === textArea.selectionEnd) { + if (wrapped) { + pos = textArea.selectionStart - tag.length; + } else { + pos = textArea.selectionStart; + } + + if (removedLastNewLine) { + pos -= 1; + } + + return textArea.setSelectionRange(pos, pos); + } +}; + +textUtils.updateText = function(textArea, tag, blockTag, wrap) { + var $textArea, selected, text; + $textArea = $(textArea); + textArea = $textArea.get(0); + text = $textArea.val(); + selected = this.selectedText(text, textArea); + $textArea.focus(); + return this.insertText(textArea, text, tag, blockTag, selected, wrap); +}; + +textUtils.init = function(form) { + var self; + self = this; + return $('.js-md', form).off('click').on('click', function() { + var $this; + $this = $(this); + return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend')); + }); +}; + +textUtils.removeListeners = function(form) { + return $('.js-md', form).off('click'); +}; + +textUtils.replaceRange = function(s, start, end, substitute) { + return s.substring(0, start) + substitute + s.substring(end); +}; + +export default textUtils; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 0decfff6921..5f027769d4f 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -1,18 +1,13 @@ -/* eslint-disable import/prefer-default-export, func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, quotes, one-var, one-var-declaration-per-line, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, no-empty, max-len, consistent-return, no-unused-vars, no-return-assign, max-len, vars-on-top */ - -import 'vendor/latinise'; - -var base; -var w = window; -if (w.gl == null) { - w.gl = {}; -} -if ((base = w.gl).text == null) { - base.text = {}; -} -gl.text.addDelimiter = function(text) { - return text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") : text; -}; +/** + * Adds a , to a string composed by numbers, at every 3 chars. + * + * 2333 -> 2,333 + * 232324 -> 232,324 + * + * @param {String} text + * @returns {String} + */ +export const addDelimiter = text => (text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : text); /** * Returns '99+' for numbers bigger than 99. @@ -20,182 +15,53 @@ gl.text.addDelimiter = function(text) { * @param {Number} count * @return {Number|String} */ -export function highCountTrim(count) { - return count > 99 ? '99+' : count; -} +export const highCountTrim = count => (count > 99 ? '99+' : count); +/** + * Capitalizes first character + * + * @param {String} text + * @return {String} + */ export function capitalizeFirstCharacter(text) { return `${text[0].toUpperCase()}${text.slice(1)}`; } -gl.text.randomString = function() { - return Math.random().toString(36).substring(7); -}; -gl.text.replaceRange = function(s, start, end, substitute) { - return s.substring(0, start) + substitute + s.substring(end); -}; -gl.text.getTextWidth = function(text, font) { - /** - * Uses canvas.measureText to compute and return the width of the given text of given font in pixels. - * - * @param {String} text The text to be rendered. - * @param {String} font The css font descriptor that text is to be rendered with (e.g. "bold 14px verdana"). - * - * @see http://stackoverflow.com/questions/118241/calculate-text-width-with-javascript/21015393#21015393 - */ - // re-use canvas object for better performance - var canvas = gl.text.getTextWidth.canvas || (gl.text.getTextWidth.canvas = document.createElement('canvas')); - var context = canvas.getContext('2d'); - context.font = font; - return context.measureText(text).width; -}; -gl.text.selectedText = function(text, textarea) { - return text.substring(textarea.selectionStart, textarea.selectionEnd); -}; -gl.text.lineBefore = function(text, textarea) { - var split; - split = text.substring(0, textarea.selectionStart).trim().split('\n'); - return split[split.length - 1]; -}; -gl.text.lineAfter = function(text, textarea) { - return text.substring(textarea.selectionEnd).trim().split('\n')[0]; -}; -gl.text.blockTagText = function(text, textArea, blockTag, selected) { - var lineAfter, lineBefore; - lineBefore = this.lineBefore(text, textArea); - lineAfter = this.lineAfter(text, textArea); - if (lineBefore === blockTag && lineAfter === blockTag) { - // To remove the block tag we have to select the line before & after - if (blockTag != null) { - textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1); - textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1); - } - return selected; - } else { - return blockTag + "\n" + selected + "\n" + blockTag; - } -}; -gl.text.insertText = function(textArea, text, tag, blockTag, selected, wrap) { - var insertText, inserted, selectedSplit, startChar, removedLastNewLine, removedFirstNewLine, currentLineEmpty, lastNewLine; - removedLastNewLine = false; - removedFirstNewLine = false; - currentLineEmpty = false; - - // Remove the first newline - if (selected.indexOf('\n') === 0) { - removedFirstNewLine = true; - selected = selected.replace(/\n+/, ''); - } - - // Remove the last newline - if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) { - removedLastNewLine = true; - selected = selected.replace(/\n$/, ''); - } - - selectedSplit = selected.split('\n'); - - if (!wrap) { - lastNewLine = textArea.value.substr(0, textArea.selectionStart).lastIndexOf('\n'); - - // Check whether the current line is empty or consists only of spaces(=handle as empty) - if (/^\s*$/.test(textArea.value.substring(lastNewLine, textArea.selectionStart))) { - currentLineEmpty = true; - } - } - - startChar = !wrap && !currentLineEmpty && textArea.selectionStart > 0 ? '\n' : ''; - - if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) { - if (blockTag != null && blockTag !== '') { - insertText = this.blockTagText(text, textArea, blockTag, selected); - } else { - insertText = selectedSplit.map(function(val) { - if (val.indexOf(tag) === 0) { - return "" + (val.replace(tag, '')); - } else { - return "" + tag + val; - } - }).join('\n'); - } - } else { - insertText = "" + startChar + tag + selected + (wrap ? tag : ' '); - } +/** + * Converst first char to uppercase and replaces undercores with spaces + * @param {String} string + * @requires {String} + */ +export const humanize = string => string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); - if (removedFirstNewLine) { - insertText = '\n' + insertText; - } +/** + * Adds an 's' to the end of the string when count is bigger than 0 + * @param {String} str + * @param {Number} count + * @returns {String} + */ +export const pluralize = (str, count) => str + (count > 1 || count === 0 ? 's' : ''); - if (removedLastNewLine) { - insertText += '\n'; - } +/** + * Replaces underscores with dashes + * @param {*} str + * @returns {String} + */ +export const dasherize = str => str.replace(/[_\s]+/g, '-'); - if (document.queryCommandSupported('insertText')) { - inserted = document.execCommand('insertText', false, insertText); - } - if (!inserted) { - try { - document.execCommand("ms-beginUndoUnit"); - } catch (error) {} - textArea.value = this.replaceRange(text, textArea.selectionStart, textArea.selectionEnd, insertText); - try { - document.execCommand("ms-endUndoUnit"); - } catch (error) {} - } - return this.moveCursor(textArea, tag, wrap, removedLastNewLine); -}; -gl.text.moveCursor = function(textArea, tag, wrapped, removedLastNewLine) { - var pos; - if (!textArea.setSelectionRange) { - return; - } - if (textArea.selectionStart === textArea.selectionEnd) { - if (wrapped) { - pos = textArea.selectionStart - tag.length; - } else { - pos = textArea.selectionStart; - } +/** + * Removes accents and converts to lower case + * @param {String} str + * @returns {String} + */ +export const slugify = str => str.trim().toLowerCase(); - if (removedLastNewLine) { - pos -= 1; - } +/** + * Truncates given text + * + * @param {String} string + * @param {Number} maxLength + * @returns {String} + */ +export const truncate = (string, maxLength) => `${string.substr(0, (maxLength - 3))}...`; - return textArea.setSelectionRange(pos, pos); - } -}; -gl.text.updateText = function(textArea, tag, blockTag, wrap) { - var $textArea, selected, text; - $textArea = $(textArea); - textArea = $textArea.get(0); - text = $textArea.val(); - selected = this.selectedText(text, textArea); - $textArea.focus(); - return this.insertText(textArea, text, tag, blockTag, selected, wrap); -}; -gl.text.init = function(form) { - var self; - self = this; - return $('.js-md', form).off('click').on('click', function() { - var $this; - $this = $(this); - return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend')); - }); -}; -gl.text.removeListeners = function(form) { - return $('.js-md', form).off('click'); -}; -gl.text.humanize = function(string) { - return string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); -}; -gl.text.pluralize = function(str, count) { - return str + (count > 1 || count === 0 ? 's' : ''); -}; -gl.text.truncate = function(string, maxLength) { - return string.substr(0, (maxLength - 3)) + '...'; -}; -gl.text.dasherize = function(str) { - return str.replace(/[_\s]+/g, '-'); -}; -gl.text.slugify = function(str) { - return str.trim().toLowerCase().latinise(); -}; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 31c5cfc5e55..127fddcf8d3 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -30,7 +30,6 @@ import './commit/image_file'; import { handleLocationHash } from './lib/utils/common_utils'; import './lib/utils/datetime_utility'; import './lib/utils/pretty_time'; -import './lib/utils/text_utility'; import './lib/utils/url_utility'; // behaviors diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index af0658eb668..d30ff12bb59 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -5,6 +5,7 @@ import 'vendor/jquery.waitforimages'; import TaskList from './task_list'; import './merge_request_tabs'; import IssuablesHelper from './helpers/issuables_helper'; +import { addDelimiter } from './lib/utils/text_utility'; (function() { this.MergeRequest = (function() { @@ -124,7 +125,7 @@ import IssuablesHelper from './helpers/issuables_helper'; const $el = $('.nav-links .js-merge-counter'); const count = Math.max((parseInt($el.text().replace(/[^\d]/, ''), 10) - by), 0); - $el.text(gl.text.addDelimiter(count)); + $el.text(addDelimiter(count)); }; MergeRequest.prototype.hideCloseButton = function() { diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue index 547140b1a43..19d8e1f49cf 100644 --- a/app/assets/javascripts/pipelines/components/graph/action_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -1,7 +1,7 @@ <script> import tooltip from '../../../vue_shared/directives/tooltip'; import icon from '../../../vue_shared/components/icon.vue'; - + import { dasherize } from '../../../lib/utils/text_utility'; /** * Renders either a cancel, retry or play icon pointing to the given path. * TODO: Remove UJS from here and use an async request instead. @@ -39,7 +39,7 @@ computed: { cssClass() { - const actionIconDash = gl.text.dasherize(this.actionIcon); + const actionIconDash = dasherize(this.actionIcon); return `${actionIconDash} js-icon-${actionIconDash}`; }, }, diff --git a/app/assets/javascripts/repo/stores/actions.js b/app/assets/javascripts/repo/stores/actions.js index be290c268b1..120ce96f44d 100644 --- a/app/assets/javascripts/repo/stores/actions.js +++ b/app/assets/javascripts/repo/stores/actions.js @@ -3,7 +3,7 @@ import flash from '../../flash'; import service from '../services'; import * as types from './mutation_types'; -export const redirectToUrl = url => gl.utils.visitUrl(url); +export const redirectToUrl = (_, url) => gl.utils.visitUrl(url); export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); @@ -84,7 +84,7 @@ export const commitChanges = ({ commit, state, dispatch, getters }, { payload, n flash(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice'); if (newMr) { - redirectToUrl(`${state.endpoints.newMergeRequestUrl}${branch}`); + dispatch('redirectToUrl', `${state.endpoints.newMergeRequestUrl}${branch}`); } else { commit(types.SET_COMMIT_REF, data.id); diff --git a/app/assets/javascripts/repo/stores/actions/branch.js b/app/assets/javascripts/repo/stores/actions/branch.js index b81a70dfd1e..61d9a5af3e3 100644 --- a/app/assets/javascripts/repo/stores/actions/branch.js +++ b/app/assets/javascripts/repo/stores/actions/branch.js @@ -3,16 +3,16 @@ import * as types from '../mutation_types'; import { pushState } from '../utils'; // eslint-disable-next-line import/prefer-default-export -export const createNewBranch = ({ rootState, commit }, branch) => service.createBranch( - rootState.project.id, +export const createNewBranch = ({ state, commit }, branch) => service.createBranch( + state.project.id, { branch, - ref: rootState.currentBranch, + ref: state.currentBranch, }, ).then(res => res.json()) .then((data) => { const branchName = data.name; - const url = location.href.replace(rootState.currentBranch, branchName); + const url = location.href.replace(state.currentBranch, branchName); pushState(url); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js index 219ff94924e..13e4cb5717e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js @@ -1,5 +1,5 @@ import tooltip from '../../vue_shared/directives/tooltip'; -import '../../lib/utils/text_utility'; +import { pluralize } from '../../lib/utils/text_utility'; export default { name: 'MRWidgetHeader', @@ -14,7 +14,7 @@ export default { return this.mr.divergedCommitsCount > 0; }, commitsText() { - return gl.text.pluralize('commit', this.mr.divergedCommitsCount); + return pluralize('commit', this.mr.divergedCommitsCount); }, branchNameClipboardData() { // This supports code in app/assets/javascripts/copy_to_clipboard.js that diff --git a/app/assets/javascripts/wikis.js b/app/assets/javascripts/wikis.js index a0025ddb598..7a865587444 100644 --- a/app/assets/javascripts/wikis.js +++ b/app/assets/javascripts/wikis.js @@ -1,4 +1,5 @@ import bp from './breakpoints'; +import { slugify } from './lib/utils/text_utility'; export default class Wikis { constructor() { @@ -23,7 +24,7 @@ export default class Wikis { if (!this.newWikiForm) return; const slugInput = this.newWikiForm.querySelector('#new_wiki_path'); - const slug = gl.text.slugify(slugInput.value); + const slug = slugify(slugInput.value); if (slug.length > 0) { const wikisPath = slugInput.getAttribute('data-wikis-path'); diff --git a/app/finders/autocomplete_users_finder.rb b/app/finders/autocomplete_users_finder.rb index b8f52e31926..c3f5358b577 100644 --- a/app/finders/autocomplete_users_finder.rb +++ b/app/finders/autocomplete_users_finder.rb @@ -45,7 +45,7 @@ class AutocompleteUsersFinder def find_users return users_from_project if project - return group.users if group + return group.users_with_parents if group return User.all if current_user User.none diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index 710fc1ed647..7026f565706 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -86,6 +86,14 @@ module Milestoneish false end + def total_issue_time_spent + @total_issue_time_spent ||= issues.joins(:timelogs).sum(:time_spent) + end + + def human_total_issue_time_spent + Gitlab::TimeTrackingFormatter.output(total_issue_time_spent) + end + private def count_issues_by_state(user) diff --git a/app/models/issue.rb b/app/models/issue.rb index 3b3c7fb7f8b..b5abc8f57b0 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -262,10 +262,6 @@ class Issue < ActiveRecord::Base true end - def update_project_counter_caches? - state_changed? || confidential_changed? - end - def update_project_counter_caches Projects::OpenIssuesCountService.new(project).refresh_cache end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index dd4e67bc9da..f1a5cc73e83 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -958,10 +958,6 @@ class MergeRequest < ActiveRecord::Base true end - def update_project_counter_caches? - state_changed? - end - def update_project_counter_caches Projects::OpenMergeRequestsCountService.new(target_project).refresh_cache end diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 2e824cda525..43c77f3f2a2 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -69,6 +69,10 @@ class PagesDomain < ActiveRecord::Base current < x509.not_before || x509.not_after < current end + def expiration + x509&.not_after + end + def subject return unless x509 x509.subject.to_s diff --git a/app/services/ci/pipeline_trigger_service.rb b/app/services/ci/pipeline_trigger_service.rb index 120af8c1e61..a9813d774bb 100644 --- a/app/services/ci/pipeline_trigger_service.rb +++ b/app/services/ci/pipeline_trigger_service.rb @@ -1,5 +1,7 @@ module Ci class PipelineTriggerService < BaseService + include Gitlab::Utils::StrongMemoize + def execute if trigger_from_token create_pipeline_from_trigger(trigger_from_token) @@ -26,9 +28,9 @@ module Ci end def trigger_from_token - return @trigger if defined?(@trigger) - - @trigger = Ci::Trigger.find_by_token(params[:token].to_s) + strong_memoize(:trigger) do + Ci::Trigger.find_by_token(params[:token].to_s) + end end def create_pipeline_variables!(pipeline) diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 68b49d880f7..90865867ff0 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -187,7 +187,7 @@ class IssuableBaseService < BaseService # We have to perform this check before saving the issuable as Rails resets # the changed fields upon calling #save. - update_project_counters = issuable.project && issuable.update_project_counter_caches? + update_project_counters = issuable.project && update_project_counter_caches?(issuable) if issuable.with_transaction_returning_status { issuable.save } # We do not touch as it will affect a update on updated_at field @@ -288,4 +288,8 @@ class IssuableBaseService < BaseService # override if needed def execute_hooks(issuable, action = 'open', params = {}) end + + def update_project_counter_caches?(issuable) + issuable.state_changed? + end end diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index 735257c4779..b680eaf5a49 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -45,5 +45,9 @@ module Issues params.delete(:assignee_ids) end end + + def update_project_counter_caches?(issue) + super || issue.confidential_changed? + end end end diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index 4965dffab9d..4f60be698e9 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -64,7 +64,7 @@ %th Projects %th Jobs %th Tags - %th Last contact + %th= link_to 'Last contact', admin_runners_path(params.slice(:search).merge(sort: 'contacted_asc')) %th - @runners.each do |runner| diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml index 85e4170aee9..6d9c6b5572a 100644 --- a/app/views/doorkeeper/authorizations/new.html.haml +++ b/app/views/doorkeeper/authorizations/new.html.haml @@ -1,5 +1,3 @@ -- auth_app_owner = @pre_auth.client.application.owner - %main{ :role => "main" } .modal-no-backdrop.modal-doorkeepr-auth .modal-content @@ -20,9 +18,14 @@ %p An application called = link_to @pre_auth.client.name, @pre_auth.redirect_uri, target: '_blank', rel: 'noopener noreferrer' - is requesting access to your GitLab account. This application was created by - = succeed "." do - = link_to auth_app_owner.name, user_path(auth_app_owner) + is requesting access to your GitLab account. + + - auth_app_owner = @pre_auth.client.application.owner + - if auth_app_owner + This application was created by + = succeed "." do + = link_to auth_app_owner.name, user_path(auth_app_owner) + Please note that this application is not provided by GitLab and you should verify its authenticity before allowing access. - if @pre_auth.scopes diff --git a/app/views/notify/_note_email.html.haml b/app/views/notify/_note_email.html.haml index a80518f7986..3e36da31ea3 100644 --- a/app/views/notify/_note_email.html.haml +++ b/app/views/notify/_note_email.html.haml @@ -1,10 +1,15 @@ - discussion = @note.discussion if @note.part_of_discussion? +- diff_discussion = discussion&.diff_discussion? +- on_image = discussion.on_image? if diff_discussion + - if discussion + - phrase_end_char = on_image ? "." : ":" + %p.details - = succeed ':' do + = succeed phrase_end_char do = link_to @note.author_name, user_url(@note.author) - - if discussion.diff_discussion? + - if diff_discussion - if discussion.new_discussion? started a new discussion - else @@ -21,7 +26,7 @@ %p.details #{link_to @note.author_name, user_url(@note.author)} commented: -- if discussion&.diff_discussion? +- if diff_discussion && !on_image = content_for :head do = stylesheet_link_tag 'mailers/highlighted_diff_email' diff --git a/app/views/projects/protected_tags/_create_protected_tag.html.haml b/app/views/projects/protected_tags/_create_protected_tag.html.haml index ea91e8af70e..f53b81cada6 100644 --- a/app/views/projects/protected_tags/_create_protected_tag.html.haml +++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml @@ -2,7 +2,7 @@ .create_access_levels-container = dropdown_tag('Select', options: { toggle_class: 'js-allowed-to-create wide', - dropdown_class: 'dropdown-menu-selectable', + dropdown_class: 'dropdown-menu-selectable capitalize-header', data: { field_name: 'protected_tag[create_access_levels_attributes][0][access_level]', input_id: 'create_access_levels_attributes' }}) = render 'projects/protected_tags/shared/create_protected_tag' diff --git a/app/views/shared/icons/_icon_hourglass.svg b/app/views/shared/icons/_icon_hourglass.svg new file mode 100644 index 00000000000..fe7e497ce13 --- /dev/null +++ b/app/views/shared/icons/_icon_hourglass.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M10.331 4.889A2.988 2.988 0 0 0 11 3V2H5v1c0 .362.064.709.182 1.03l5.15.859zM3 14v-1c0-1.78.93-3.342 2.33-4.228.447-.327.67-.582.67-.764 0-.19-.242-.46-.725-.815A4.996 4.996 0 0 1 3 3V2H2a1 1 0 1 1 0-2h12a1 1 0 0 1 0 2h-1v1a4.997 4.997 0 0 1-2.39 4.266c-.407.3-.61.545-.61.734 0 .19.203.434.61.734A4.997 4.997 0 0 1 13 13v1h1a1 1 0 0 1 0 2H2a1 1 0 0 1 0-2h1zm8 0v-1a3 3 0 0 0-6 0v1h6z"/></svg> diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index f03e0ab154c..4f51455c26e 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -85,6 +85,22 @@ Closed: = milestone.issues_visible_to_user(current_user).closed.count + .block.time_spent + .sidebar-collapsed-icon + = custom_icon('icon_hourglass') + %span.collapsed-milestone-total-time-spent + - if milestone.human_total_issue_time_spent + = milestone.human_total_issue_time_spent + - else + = _("None") + .title.hide-collapsed + = _("Total issue time spent") + .value.hide-collapsed + - if milestone.human_total_issue_time_spent + %span.bold= milestone.human_total_issue_time_spent + - else + %span.no-value= _("No time spent") + .block.merge-requests .sidebar-collapsed-icon %strong diff --git a/changelogs/unreleased/32059-fix-oauth-phishing.yml b/changelogs/unreleased/32059-fix-oauth-phishing.yml deleted file mode 100644 index 1aaa7285309..00000000000 --- a/changelogs/unreleased/32059-fix-oauth-phishing.yml +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: Prevent OAuth phishing attack by presenting detailed wording about app to user - during authorization -merge_request: -author: -type: security diff --git a/changelogs/unreleased/38075_allow_refernce_integer_labels.yml b/changelogs/unreleased/38075_allow_refernce_integer_labels.yml new file mode 100644 index 00000000000..b5342d4adf8 --- /dev/null +++ b/changelogs/unreleased/38075_allow_refernce_integer_labels.yml @@ -0,0 +1,5 @@ +--- +title: Fix errors when selecting numeric-only labels in the labels autocomplete selector +merge_request: 14607 +author: haseebeqx +type: fixed diff --git a/changelogs/unreleased/38385-gpg-tooltips-not-working-in-safari.yml b/changelogs/unreleased/38385-gpg-tooltips-not-working-in-safari.yml deleted file mode 100644 index c7e840f0723..00000000000 --- a/changelogs/unreleased/38385-gpg-tooltips-not-working-in-safari.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix GPG signature popup info in Safari and Firefox -merge_request: 15228 -author: -type: fixed diff --git a/changelogs/unreleased/39335-add-time-spend-to-milestones.yml b/changelogs/unreleased/39335-add-time-spend-to-milestones.yml new file mode 100644 index 00000000000..41a43418cbf --- /dev/null +++ b/changelogs/unreleased/39335-add-time-spend-to-milestones.yml @@ -0,0 +1,5 @@ +--- +title: Add total time spent to milestones +merge_request: 15116 +author: George Andrinopoulos +type: added diff --git a/changelogs/unreleased/39436-pages-api-administrative.yml b/changelogs/unreleased/39436-pages-api-administrative.yml new file mode 100644 index 00000000000..f38bbbd479c --- /dev/null +++ b/changelogs/unreleased/39436-pages-api-administrative.yml @@ -0,0 +1,5 @@ +--- +title: Add administrative endpoint to list all pages domains +merge_request: 15160 +author: Travis Miller +type: added diff --git a/changelogs/unreleased/39602-move-update-project-counter-caches-out-of-issues-merge-requests.yml b/changelogs/unreleased/39602-move-update-project-counter-caches-out-of-issues-merge-requests.yml new file mode 100644 index 00000000000..056afe43010 --- /dev/null +++ b/changelogs/unreleased/39602-move-update-project-counter-caches-out-of-issues-merge-requests.yml @@ -0,0 +1,5 @@ +--- +title: Move update_project_counter_caches? out of issue and merge request +merge_request: 15300 +author: George Andrinopoulos +type: other diff --git a/changelogs/unreleased/39704_fix_webhooks_log_time.yml b/changelogs/unreleased/39704_fix_webhooks_log_time.yml deleted file mode 100644 index 1234663e66b..00000000000 --- a/changelogs/unreleased/39704_fix_webhooks_log_time.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix webhooks recent deliveries -merge_request: 15146 -author: Alexander Randa (@randaalex) -type: fixed diff --git a/changelogs/unreleased/40068-runner-sorting-regression.yml b/changelogs/unreleased/40068-runner-sorting-regression.yml new file mode 100644 index 00000000000..6a2bd59d6d6 --- /dev/null +++ b/changelogs/unreleased/40068-runner-sorting-regression.yml @@ -0,0 +1,5 @@ +--- +title: Revert a regression on runners sorting (!15134) +merge_request: 15341 +author: Takuya Noguchi +type: fixed diff --git a/changelogs/unreleased/bvl-unlink-fixes.yml b/changelogs/unreleased/bvl-unlink-fixes.yml deleted file mode 100644 index 685d78f479d..00000000000 --- a/changelogs/unreleased/bvl-unlink-fixes.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix issues with forked projects of which the source was deleted -merge_request: 15150 -author: -type: fixed diff --git a/changelogs/unreleased/dm-authorize-admin-oauth-application.yml b/changelogs/unreleased/dm-authorize-admin-oauth-application.yml new file mode 100644 index 00000000000..2787485bc28 --- /dev/null +++ b/changelogs/unreleased/dm-authorize-admin-oauth-application.yml @@ -0,0 +1,6 @@ +--- +title: Prevent error when authorizing an admin-created OAauth application without + a set owner +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/dm-block-group-and-project-creation-when-external-by-default.yml b/changelogs/unreleased/dm-block-group-and-project-creation-when-external-by-default.yml deleted file mode 100644 index 42bcf9b1edd..00000000000 --- a/changelogs/unreleased/dm-block-group-and-project-creation-when-external-by-default.yml +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: Make sure group and project creation is blocked for new users that are external - by default -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/fix-import-export-arguments.yml b/changelogs/unreleased/fix-import-export-arguments.yml deleted file mode 100644 index eee87e313ea..00000000000 --- a/changelogs/unreleased/fix-import-export-arguments.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix arguments Import/Export error importing project merge requests -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/fix-subgroup-autocomplete.yml b/changelogs/unreleased/fix-subgroup-autocomplete.yml new file mode 100644 index 00000000000..4baa2b02f77 --- /dev/null +++ b/changelogs/unreleased/fix-subgroup-autocomplete.yml @@ -0,0 +1,5 @@ +--- +title: Fix user autocomplete in subgroups +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/fix_diff_parsing.yml b/changelogs/unreleased/fix_diff_parsing.yml deleted file mode 100644 index 7a26b4f9ff5..00000000000 --- a/changelogs/unreleased/fix_diff_parsing.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix diff parser so it tolerates to diff special markers in the content -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/fix_migration_that_adds_ff_merge_field.yml b/changelogs/unreleased/fix_migration_that_adds_ff_merge_field.yml deleted file mode 100644 index a1685497331..00000000000 --- a/changelogs/unreleased/fix_migration_that_adds_ff_merge_field.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix a migration that adds merge_requests_ff_only_enabled column to MR table -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/issue_39176.yml b/changelogs/unreleased/issue_39176.yml deleted file mode 100644 index 6255b51c094..00000000000 --- a/changelogs/unreleased/issue_39176.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Render 404 when polling commit notes without having permissions -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/issue_39238.yml b/changelogs/unreleased/issue_39238.yml new file mode 100644 index 00000000000..75a4969ca9e --- /dev/null +++ b/changelogs/unreleased/issue_39238.yml @@ -0,0 +1,5 @@ +--- +title: Fix image diff notification email from showing wrong content +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/issue_39773_be.yml b/changelogs/unreleased/issue_39773_be.yml deleted file mode 100644 index db30267d71f..00000000000 --- a/changelogs/unreleased/issue_39773_be.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Show error message when fast-forward merge is not possible -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/jivl-fix-cancel-button-file-upload-new-issue.yml b/changelogs/unreleased/jivl-fix-cancel-button-file-upload-new-issue.yml deleted file mode 100644 index 0205d9626b1..00000000000 --- a/changelogs/unreleased/jivl-fix-cancel-button-file-upload-new-issue.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix cancel button not working while uploading on the new issue page -merge_request: 15137 -author: -type: fixed diff --git a/changelogs/unreleased/pawel-disable_nfs_metrics_checks_39730.yml b/changelogs/unreleased/pawel-disable_nfs_metrics_checks_39730.yml deleted file mode 100644 index 556d7d069d3..00000000000 --- a/changelogs/unreleased/pawel-disable_nfs_metrics_checks_39730.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Remove Filesystem check metrics that use too much CPU to handle requests -merge_request: -author: -type: performance diff --git a/changelogs/unreleased/sh-fix-environment-slug-generation.yml b/changelogs/unreleased/sh-fix-environment-slug-generation.yml deleted file mode 100644 index 8a9c670c52c..00000000000 --- a/changelogs/unreleased/sh-fix-environment-slug-generation.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Avoid regenerating the ref path for the environment -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/text-utils.yml b/changelogs/unreleased/text-utils.yml new file mode 100644 index 00000000000..b95bb82fe01 --- /dev/null +++ b/changelogs/unreleased/text-utils.yml @@ -0,0 +1,5 @@ +--- +title: Export text utils functions as es6 module and add tests +merge_request: +author: +type: other diff --git a/doc/api/pages_domains.md b/doc/api/pages_domains.md index 51962595e33..50685f335f7 100644 --- a/doc/api/pages_domains.md +++ b/doc/api/pages_domains.md @@ -4,6 +4,31 @@ Endpoints for connecting custom domain(s) and TLS certificates in [GitLab Pages] The GitLab Pages feature must be enabled to use these endpoints. Find out more about [administering](../administration/pages/index.md) and [using](../user/project/pages/index.md) the feature. +## List all pages domains + +Get a list of all pages domains. The user must have admin permissions. + +```http +GET /pages/domains +``` + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/pages/domains +``` + +```json +[ + { + "domain": "ssl.domain.example", + "url": "https://ssl.domain.example", + "certificate": { + "expired": false, + "expiration": "2020-04-12T14:32:00.000Z" + } + } +] +``` + ## List pages domains Get a list of project pages domains. The user must have permissions to view pages domains. diff --git a/doc/api/services.md b/doc/api/services.md index 08a2bee1518..08df26db3ec 100644 --- a/doc/api/services.md +++ b/doc/api/services.md @@ -490,6 +490,41 @@ Remove all previously JIRA settings from a project. DELETE /projects/:id/services/jira ``` +## Kubernetes + +Kubernetes / Openshift integration + +### Create/Edit Kubernetes service + +Set Kubernetes service for a project. + +``` +PUT /projects/:id/services/kubernetes +``` + +Parameters: + +- `namespace` (**required**) - The Kubernetes namespace to use +- `api_url` (**required**) - The URL to the Kubernetes cluster API, e.g., https://kubernetes.example.com +- `token` (**required**) - The service token to authenticate against the Kubernetes cluster with +- `ca_pem` (optional) - A custom certificate authority bundle to verify the Kubernetes cluster with (PEM format) + +### Delete Kubernetes service + +Delete Kubernetes service for a project. + +``` +DELETE /projects/:id/services/kubernetes +``` + +### Get Kubernetes service settings + +Get Kubernetes service settings for a project. + +``` +GET /projects/:id/services/kubernetes +``` + ## Slack slash commands Ability to receive slash commands from a Slack chat instance. diff --git a/doc/user/project/members/index.md b/doc/user/project/members/index.md index b8dd96087f1..43713855e26 100644 --- a/doc/user/project/members/index.md +++ b/doc/user/project/members/index.md @@ -21,7 +21,7 @@ want to add. --- -Select the user and the [permission level](../../user/permissions.md) +Select the user and the [permission level](../../permissions.md) that you'd like to give the user. Note that you can select more than one user. ![Give user permissions](img/add_user_give_permissions.png) diff --git a/lib/api/api_guard.rb b/lib/api/api_guard.rb index b9c7d443f6c..c1c0d344917 100644 --- a/lib/api/api_guard.rb +++ b/lib/api/api_guard.rb @@ -42,6 +42,8 @@ module API # Helper Methods for Grape Endpoint module HelperMethods + include Gitlab::Utils::StrongMemoize + def find_current_user! user = find_user_from_access_token || find_user_from_warden return unless user @@ -52,9 +54,9 @@ module API end def access_token - return @access_token if defined?(@access_token) - - @access_token = find_oauth_access_token || find_personal_access_token + strong_memoize(:access_token) do + find_oauth_access_token || find_personal_access_token + end end def validate_access_token!(scopes: []) diff --git a/lib/api/entities.rb b/lib/api/entities.rb index a382db92e8d..16ae99b5c6c 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -1042,6 +1042,11 @@ module API expose :value end + class PagesDomainCertificateExpiration < Grape::Entity + expose :expired?, as: :expired + expose :expiration + end + class PagesDomainCertificate < Grape::Entity expose :subject expose :expired?, as: :expired @@ -1049,12 +1054,23 @@ module API expose :certificate_text end + class PagesDomainBasic < Grape::Entity + expose :domain + expose :url + expose :certificate, + as: :certificate_expiration, + if: ->(pages_domain, _) { pages_domain.certificate? }, + using: PagesDomainCertificateExpiration do |pages_domain| + pages_domain + end + end + class PagesDomain < Grape::Entity expose :domain expose :url expose :certificate, - if: ->(pages_domain, _) { pages_domain.certificate? }, - using: PagesDomainCertificate do |pages_domain| + if: ->(pages_domain, _) { pages_domain.certificate? }, + using: PagesDomainCertificate do |pages_domain| pages_domain end end diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 5f9b94cc89c..3c8960cb1ab 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -155,6 +155,11 @@ module API end end + def authenticated_with_full_private_access! + authenticate! + forbidden! unless current_user.full_private_access? + end + def authenticated_as_admin! authenticate! forbidden! unless current_user.admin? @@ -190,6 +195,10 @@ module API not_found! unless user_project.pages_available? end + def require_pages_config_enabled! + not_found! unless Gitlab.config.pages.enabled + end + def can?(object, action, subject = :global) Ability.allowed?(object, action, subject) end diff --git a/lib/api/pages_domains.rb b/lib/api/pages_domains.rb index 259f3f34068..d7b613a717e 100644 --- a/lib/api/pages_domains.rb +++ b/lib/api/pages_domains.rb @@ -4,7 +4,6 @@ module API before do authenticate! - require_pages_enabled! end after_validation do @@ -29,10 +28,31 @@ module API end end + resource :pages do + before do + require_pages_config_enabled! + authenticated_with_full_private_access! + end + + desc "Get all pages domains" do + success Entities::PagesDomainBasic + end + params do + use :pagination + end + get "domains" do + present paginate(PagesDomain.all), with: Entities::PagesDomainBasic + end + end + params do requires :id, type: String, desc: 'The ID of a project' end resource :projects, requirements: { id: %r{[^/]+} } do + before do + require_pages_enabled! + end + desc 'Get all pages domains' do success Entities::PagesDomain end diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 14a8d551524..cfb88a0c12b 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -104,7 +104,7 @@ module Gitlab end def exists? - Gitlab::GitalyClient.migrate(:repository_exists) do |enabled| + Gitlab::GitalyClient.migrate(:repository_exists, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |enabled| if enabled gitaly_repository_client.exists? else diff --git a/lib/gitlab/utils/strong_memoize.rb b/lib/gitlab/utils/strong_memoize.rb new file mode 100644 index 00000000000..a2ac9285b56 --- /dev/null +++ b/lib/gitlab/utils/strong_memoize.rb @@ -0,0 +1,31 @@ +module Gitlab + module Utils + module StrongMemoize + # Instead of writing patterns like this: + # + # def trigger_from_token + # return @trigger if defined?(@trigger) + # + # @trigger = Ci::Trigger.find_by_token(params[:token].to_s) + # end + # + # We could write it like: + # + # def trigger_from_token + # strong_memoize(:trigger) do + # Ci::Trigger.find_by_token(params[:token].to_s) + # end + # end + # + def strong_memoize(name) + ivar_name = "@#{name}" + + if instance_variable_defined?(ivar_name) + instance_variable_get(ivar_name) + else + instance_variable_set(ivar_name, yield) + end + end + end + end +end diff --git a/qa/qa/page/base.rb b/qa/qa/page/base.rb index d55326c5262..bdddfb877c5 100644 --- a/qa/qa/page/base.rb +++ b/qa/qa/page/base.rb @@ -5,7 +5,7 @@ module QA include Scenario::Actable def refresh - visit current_path + visit current_url end end end diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb index b8a66245153..95d637265e0 100644 --- a/spec/features/issues/gfm_autocomplete_spec.rb +++ b/spec/features/issues/gfm_autocomplete_spec.rb @@ -218,18 +218,18 @@ feature 'GFM autocomplete', :js do user_item = find('.atwho-view li', text: user.username) expect(user_item).to have_content(user.username) end + end - def expect_to_wrap(should_wrap, item, note, value) - expect(item).to have_content(value) - expect(item).not_to have_content("\"#{value}\"") + def expect_to_wrap(should_wrap, item, note, value) + expect(item).to have_content(value) + expect(item).not_to have_content("\"#{value}\"") - item.click + item.click - if should_wrap - expect(note.value).to include("\"#{value}\"") - else - expect(note.value).not_to include("\"#{value}\"") - end + if should_wrap + expect(note.value).to include("\"#{value}\"") + else + expect(note.value).not_to include("\"#{value}\"") end end end diff --git a/spec/features/merge_requests/create_new_mr_spec.rb b/spec/features/merge_requests/create_new_mr_spec.rb index 5402d61da54..db5ce2d11a8 100644 --- a/spec/features/merge_requests/create_new_mr_spec.rb +++ b/spec/features/merge_requests/create_new_mr_spec.rb @@ -67,6 +67,28 @@ feature 'Create New Merge Request', :js do expect(page).to have_content 'git checkout -b orphaned-branch origin/orphaned-branch' end + it 'allows filtering multiple dropdowns' do + visit project_new_merge_request_path(project) + + first('.js-source-branch').click + + input = find('.dropdown-source-branch .dropdown-input-field') + input.click + input.send_keys('orphaned-branch') + + find('.dropdown-source-branch .dropdown-content li', match: :first) + source_items = all('.dropdown-source-branch .dropdown-content li') + + expect(source_items.count).to eq(1) + + first('.js-target-branch').click + + find('.dropdown-target-branch .dropdown-content li', match: :first) + target_items = all('.dropdown-target-branch .dropdown-content li') + + expect(target_items.count).to be > 1 + end + context 'when target project cannot be viewed by the current user' do it 'does not leak the private project name & namespace' do private_project = create(:project, :private, :repository) diff --git a/spec/features/milestone_spec.rb b/spec/features/milestone_spec.rb index 6c9dc67ad74..27efc32c95b 100644 --- a/spec/features/milestone_spec.rb +++ b/spec/features/milestone_spec.rb @@ -65,4 +65,33 @@ feature 'Milestone' do expect(find('.alert-danger')).to have_content('already being used for another group or project milestone.') end end + + feature 'Open a milestone' do + scenario 'shows total issue time spent correctly when no time has been logged' do + milestone = create(:milestone, project: project, title: 8.7) + + visit project_milestone_path(project, milestone) + + page.within('.block.time_spent') do + expect(page).to have_content 'No time spent' + expect(page).to have_content 'None' + end + end + + scenario 'shows total issue time spent' do + milestone = create(:milestone, project: project, title: 8.7) + issue1 = create(:issue, project: project, milestone: milestone) + issue2 = create(:issue, project: project, milestone: milestone) + issue1.spend_time(duration: 3600, user: user) + issue1.save! + issue2.spend_time(duration: 7200, user: user) + issue2.save! + + visit project_milestone_path(project, milestone) + + page.within('.block.time_spent') do + expect(page).to have_content '3h' + end + end + end end diff --git a/spec/finders/autocomplete_users_finder_spec.rb b/spec/finders/autocomplete_users_finder_spec.rb index 684af74d750..dcf9111776e 100644 --- a/spec/finders/autocomplete_users_finder_spec.rb +++ b/spec/finders/autocomplete_users_finder_spec.rb @@ -42,6 +42,21 @@ describe AutocompleteUsersFinder do it { is_expected.to match_array([user1]) } end + context 'when passed a subgroup', :nested_groups do + let(:grandparent) { create(:group, :public) } + let(:parent) { create(:group, :public, parent: grandparent) } + let(:child) { create(:group, :public, parent: parent) } + let(:group) { parent } + + let!(:grandparent_user) { create(:group_member, :developer, group: grandparent).user } + let!(:parent_user) { create(:group_member, :developer, group: parent).user } + let!(:child_user) { create(:group_member, :developer, group: child).user } + + it 'includes users from parent groups as well' do + expect(subject).to match_array([grandparent_user, parent_user]) + end + end + it { is_expected.to match_array([user1, external_user, omniauth_user, current_user]) } context 'when filtered by search' do diff --git a/spec/fixtures/api/schemas/public_api/v4/pages_domain/basic.json b/spec/fixtures/api/schemas/public_api/v4/pages_domain/basic.json new file mode 100644 index 00000000000..4ba6422406c --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/pages_domain/basic.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "properties": { + "domain": { "type": "string" }, + "url": { "type": "uri" }, + "certificate_expiration": { + "type": "object", + "properties": { + "expired": { "type": "boolean" }, + "expiration": { "type": "string" } + }, + "required": ["expired", "expiration"], + "additionalProperties": false + } + }, + "required": ["domain", "url"], + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/public_api/v4/pages_domain/detail.json b/spec/fixtures/api/schemas/public_api/v4/pages_domain/detail.json new file mode 100644 index 00000000000..08db8d47050 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/pages_domain/detail.json @@ -0,0 +1,20 @@ +{ + "type": "object", + "properties": { + "domain": { "type": "string" }, + "url": { "type": "uri" }, + "certificate": { + "type": "object", + "properties": { + "subject": { "type": "string" }, + "expired": { "type": "boolean" }, + "certificate": { "type": "string" }, + "certificate_text": { "type": "string" } + }, + "required": ["subject", "expired"], + "additionalProperties": false + } + }, + "required": ["domain", "url"], + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/public_api/v4/pages_domain_basics.json b/spec/fixtures/api/schemas/public_api/v4/pages_domain_basics.json new file mode 100644 index 00000000000..c7d86de7d8e --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/pages_domain_basics.json @@ -0,0 +1,4 @@ +{ + "type": "array", + "items": { "$ref": "pages_domain/basic.json" } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/pages_domains.json b/spec/fixtures/api/schemas/public_api/v4/pages_domains.json index 0de1d0f1228..7c27218dc5a 100644 --- a/spec/fixtures/api/schemas/public_api/v4/pages_domains.json +++ b/spec/fixtures/api/schemas/public_api/v4/pages_domains.json @@ -1,23 +1,4 @@ { "type": "array", - "items": { - "type": "object", - "properties": { - "domain": { "type": "string" }, - "url": { "type": "uri" }, - "certificate": { - "type": "object", - "properties": { - "subject": { "type": "string" }, - "expired": { "type": "boolean" }, - "certificate": { "type": "string" }, - "certificate_text": { "type": "string" } - }, - "required": ["subject", "expired"], - "additionalProperties": false - } - }, - "required": ["domain", "url"], - "additionalProperties": false - } + "items": { "$ref": "pages_domain/detail.json" } } diff --git a/spec/javascripts/gfm_auto_complete_spec.js b/spec/javascripts/gfm_auto_complete_spec.js index ad0c7264616..6f357306ec7 100644 --- a/spec/javascripts/gfm_auto_complete_spec.js +++ b/spec/javascripts/gfm_auto_complete_spec.js @@ -67,6 +67,28 @@ describe('GfmAutoComplete', function () { }); }); + describe('DefaultOptions.beforeInsert', () => { + const beforeInsert = (context, value) => ( + gfmAutoCompleteCallbacks.beforeInsert.call(context, value) + ); + + const atwhoInstance = { setting: { skipSpecialCharacterTest: false } }; + + it('should not quote if value only contains alphanumeric charecters', () => { + expect(beforeInsert(atwhoInstance, '@user1')).toBe('@user1'); + expect(beforeInsert(atwhoInstance, '~label1')).toBe('~label1'); + }); + + it('should quote if value contains any non-alphanumeric characters', () => { + expect(beforeInsert(atwhoInstance, '~label-20')).toBe('~"label-20"'); + expect(beforeInsert(atwhoInstance, '~label 20')).toBe('~"label 20"'); + }); + + it('should quote integer labels', () => { + expect(beforeInsert(atwhoInstance, '~1234')).toBe('~"1234"'); + }); + }); + describe('DefaultOptions.matcher', function () { const defaultMatcher = (context, flag, subtext) => ( gfmAutoCompleteCallbacks.matcher.call(context, flag, subtext) diff --git a/spec/javascripts/lib/utils/text_markdown_spec.js b/spec/javascripts/lib/utils/text_markdown_spec.js new file mode 100644 index 00000000000..a95a7e2a5be --- /dev/null +++ b/spec/javascripts/lib/utils/text_markdown_spec.js @@ -0,0 +1,62 @@ +import textUtils from '~/lib/utils/text_markdown'; + +describe('init markdown', () => { + let textArea; + + beforeAll(() => { + textArea = document.createElement('textarea'); + document.querySelector('body').appendChild(textArea); + textArea.focus(); + }); + + afterAll(() => { + textArea.parentNode.removeChild(textArea); + }); + + describe('without selection', () => { + it('inserts the tag on an empty line', () => { + const initialValue = ''; + + textArea.value = initialValue; + textArea.selectionStart = 0; + textArea.selectionEnd = 0; + + textUtils.insertText(textArea, textArea.value, '*', null, '', false); + + expect(textArea.value).toEqual(`${initialValue}* `); + }); + + it('inserts the tag on a new line if the current one is not empty', () => { + const initialValue = 'some text'; + + textArea.value = initialValue; + textArea.setSelectionRange(initialValue.length, initialValue.length); + + textUtils.insertText(textArea, textArea.value, '*', null, '', false); + + expect(textArea.value).toEqual(`${initialValue}\n* `); + }); + + it('inserts the tag on the same line if the current line only contains spaces', () => { + const initialValue = ' '; + + textArea.value = initialValue; + textArea.setSelectionRange(initialValue.length, initialValue.length); + + textUtils.insertText(textArea, textArea.value, '*', null, '', false); + + expect(textArea.value).toEqual(`${initialValue}* `); + }); + + it('inserts the tag on the same line if the current line only contains tabs', () => { + const initialValue = '\t\t\t'; + + textArea.value = initialValue; + textArea.setSelectionRange(initialValue.length, initialValue.length); + + textUtils.insertText(textArea, textArea.value, '*', null, '', false); + + expect(textArea.value).toEqual(`${initialValue}* `); + }); + }); +}); diff --git a/spec/javascripts/lib/utils/text_utility_spec.js b/spec/javascripts/lib/utils/text_utility_spec.js index f2e2ce79d27..1f46c225071 100644 --- a/spec/javascripts/lib/utils/text_utility_spec.js +++ b/spec/javascripts/lib/utils/text_utility_spec.js @@ -1,115 +1,65 @@ -import * as textUtility from '~/lib/utils/text_utility'; +import * as textUtils from '~/lib/utils/text_utility'; describe('text_utility', () => { - describe('gl.text.getTextWidth', () => { - it('returns zero width when no text is passed', () => { - expect(gl.text.getTextWidth('')).toBe(0); + describe('addDelimiter', () => { + it('should add a delimiter to the given string', () => { + expect(textUtils.addDelimiter('1234')).toEqual('1,234'); + expect(textUtils.addDelimiter('222222')).toEqual('222,222'); }); - it('returns zero width when no text is passed and font is passed', () => { - expect(gl.text.getTextWidth('', '100px sans-serif')).toBe(0); - }); - - it('returns width when text is passed', () => { - expect(gl.text.getTextWidth('foo') > 0).toBe(true); - }); - - it('returns bigger width when font is larger', () => { - const largeFont = gl.text.getTextWidth('foo', '100px sans-serif'); - const regular = gl.text.getTextWidth('foo', '10px sans-serif'); - expect(largeFont > regular).toBe(true); - }); - }); - - describe('gl.text.pluralize', () => { - it('returns pluralized', () => { - expect(gl.text.pluralize('test', 2)).toBe('tests'); - }); - - it('returns pluralized when count is 0', () => { - expect(gl.text.pluralize('test', 0)).toBe('tests'); - }); - - it('does not return pluralized', () => { - expect(gl.text.pluralize('test', 1)).toBe('test'); + it('should not add a delimiter if string contains no numbers', () => { + expect(textUtils.addDelimiter('aaaa')).toEqual('aaaa'); }); }); describe('highCountTrim', () => { it('returns 99+ for count >= 100', () => { - expect(textUtility.highCountTrim(105)).toBe('99+'); - expect(textUtility.highCountTrim(100)).toBe('99+'); + expect(textUtils.highCountTrim(105)).toBe('99+'); + expect(textUtils.highCountTrim(100)).toBe('99+'); }); it('returns exact number for count < 100', () => { - expect(textUtility.highCountTrim(45)).toBe(45); + expect(textUtils.highCountTrim(45)).toBe(45); }); }); describe('capitalizeFirstCharacter', () => { it('returns string with first letter capitalized', () => { - expect(textUtility.capitalizeFirstCharacter('gitlab')).toEqual('Gitlab'); + expect(textUtils.capitalizeFirstCharacter('gitlab')).toEqual('Gitlab'); + expect(textUtils.highCountTrim(105)).toBe('99+'); + expect(textUtils.highCountTrim(100)).toBe('99+'); }); }); - describe('gl.text.insertText', () => { - let textArea; - - beforeAll(() => { - textArea = document.createElement('textarea'); - document.querySelector('body').appendChild(textArea); - textArea.focus(); + describe('humanize', () => { + it('should remove underscores and uppercase the first letter', () => { + expect(textUtils.humanize('foo_bar')).toEqual('Foo bar'); }); + }); - afterAll(() => { - textArea.parentNode.removeChild(textArea); + describe('pluralize', () => { + it('should pluralize given string', () => { + expect(textUtils.pluralize('test', 2)).toBe('tests'); }); - describe('without selection', () => { - it('inserts the tag on an empty line', () => { - const initialValue = ''; - - textArea.value = initialValue; - textArea.selectionStart = 0; - textArea.selectionEnd = 0; - - gl.text.insertText(textArea, textArea.value, '*', null, '', false); - - expect(textArea.value).toEqual(`${initialValue}* `); - }); - - it('inserts the tag on a new line if the current one is not empty', () => { - const initialValue = 'some text'; - - textArea.value = initialValue; - textArea.setSelectionRange(initialValue.length, initialValue.length); - - gl.text.insertText(textArea, textArea.value, '*', null, '', false); - - expect(textArea.value).toEqual(`${initialValue}\n* `); - }); - - it('inserts the tag on the same line if the current line only contains spaces', () => { - const initialValue = ' '; - - textArea.value = initialValue; - textArea.setSelectionRange(initialValue.length, initialValue.length); - - gl.text.insertText(textArea, textArea.value, '*', null, '', false); - - expect(textArea.value).toEqual(`${initialValue}* `); - }); - - it('inserts the tag on the same line if the current line only contains tabs', () => { - const initialValue = '\t\t\t'; + it('should pluralize when count is 0', () => { + expect(textUtils.pluralize('test', 0)).toBe('tests'); + }); - textArea.value = initialValue; - textArea.setSelectionRange(initialValue.length, initialValue.length); + it('should not pluralize when count is 1', () => { + expect(textUtils.pluralize('test', 1)).toBe('test'); + }); + }); - gl.text.insertText(textArea, textArea.value, '*', null, '', false); + describe('dasherize', () => { + it('should replace underscores with dashes', () => { + expect(textUtils.dasherize('foo_bar_foo')).toEqual('foo-bar-foo'); + }); + }); - expect(textArea.value).toEqual(`${initialValue}* `); - }); + describe('slugify', () => { + it('should remove accents and convert to lower case', () => { + expect(textUtils.slugify('João')).toEqual('joão'); }); }); }); diff --git a/spec/javascripts/repo/helpers.js b/spec/javascripts/repo/helpers.js index 376c291c64b..820a44992b4 100644 --- a/spec/javascripts/repo/helpers.js +++ b/spec/javascripts/repo/helpers.js @@ -12,9 +12,4 @@ export const file = (name = 'name', id = name, type = '') => decorateData({ url: 'url', name, path: name, - last_commit: { - id: '123', - message: 'test', - committed_date: new Date().toISOString(), - }, }); diff --git a/spec/javascripts/repo/stores/actions/branch_spec.js b/spec/javascripts/repo/stores/actions/branch_spec.js new file mode 100644 index 00000000000..af9d6835a67 --- /dev/null +++ b/spec/javascripts/repo/stores/actions/branch_spec.js @@ -0,0 +1,38 @@ +import store from '~/repo/stores'; +import service from '~/repo/services'; +import { resetStore } from '../../helpers'; + +describe('Multi-file store branch actions', () => { + afterEach(() => { + resetStore(store); + }); + + describe('createNewBranch', () => { + beforeEach(() => { + spyOn(service, 'createBranch').and.returnValue(Promise.resolve({ + json: () => ({ + name: 'testing', + }), + })); + spyOn(history, 'pushState'); + + store.state.project.id = 2; + store.state.currentBranch = 'testing'; + }); + + it('creates new branch', (done) => { + store.dispatch('createNewBranch', 'master') + .then(() => { + expect(store.state.currentBranch).toBe('testing'); + expect(service.createBranch).toHaveBeenCalledWith(2, { + branch: 'master', + ref: 'testing', + }); + expect(history.pushState).toHaveBeenCalled(); + + done(); + }) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/repo/stores/actions/file_spec.js b/spec/javascripts/repo/stores/actions/file_spec.js new file mode 100644 index 00000000000..099c0556e71 --- /dev/null +++ b/spec/javascripts/repo/stores/actions/file_spec.js @@ -0,0 +1,417 @@ +import Vue from 'vue'; +import store from '~/repo/stores'; +import service from '~/repo/services'; +import { file, resetStore } from '../../helpers'; + +describe('Multi-file store file actions', () => { + afterEach(() => { + resetStore(store); + }); + + describe('closeFile', () => { + let localFile; + let getLastCommitDataSpy; + let oldGetLastCommitData; + + beforeEach(() => { + getLastCommitDataSpy = jasmine.createSpy('getLastCommitData'); + oldGetLastCommitData = store._actions.getLastCommitData; // eslint-disable-line + store._actions.getLastCommitData = [getLastCommitDataSpy]; // eslint-disable-line + + localFile = file(); + localFile.active = true; + localFile.opened = true; + localFile.parentTreeUrl = 'parentTreeUrl'; + + store.state.openFiles.push(localFile); + + spyOn(history, 'pushState'); + }); + + afterEach(() => { + store._actions.getLastCommitData = oldGetLastCommitData; // eslint-disable-line + }); + + it('closes open files', (done) => { + store.dispatch('closeFile', { file: localFile }) + .then(() => { + expect(localFile.opened).toBeFalsy(); + expect(localFile.active).toBeFalsy(); + expect(store.state.openFiles.length).toBe(0); + + done(); + }).catch(done.fail); + }); + + it('does not close file if has changed', (done) => { + localFile.changed = true; + + store.dispatch('closeFile', { file: localFile }) + .then(() => { + expect(localFile.opened).toBeTruthy(); + expect(localFile.active).toBeTruthy(); + expect(store.state.openFiles.length).toBe(1); + + done(); + }).catch(done.fail); + }); + + it('does not close file if temp file', (done) => { + localFile.tempFile = true; + + store.dispatch('closeFile', { file: localFile }) + .then(() => { + expect(localFile.opened).toBeTruthy(); + expect(localFile.active).toBeTruthy(); + expect(store.state.openFiles.length).toBe(1); + + done(); + }).catch(done.fail); + }); + + it('force closes a changed file', (done) => { + localFile.changed = true; + + store.dispatch('closeFile', { file: localFile, force: true }) + .then(() => { + expect(localFile.opened).toBeFalsy(); + expect(localFile.active).toBeFalsy(); + expect(store.state.openFiles.length).toBe(0); + + done(); + }).catch(done.fail); + }); + + it('calls pushState when no open files are left', (done) => { + store.dispatch('closeFile', { file: localFile }) + .then(() => { + expect(history.pushState).toHaveBeenCalledWith(jasmine.anything(), '', 'parentTreeUrl'); + + done(); + }).catch(done.fail); + }); + + it('sets next file as active', (done) => { + const f = file(); + store.state.openFiles.push(f); + + expect(f.active).toBeFalsy(); + + store.dispatch('closeFile', { file: localFile }) + .then(() => { + expect(f.active).toBeTruthy(); + + done(); + }).catch(done.fail); + }); + + it('calls getLastCommitData', (done) => { + store.dispatch('closeFile', { file: localFile }) + .then(() => { + expect(getLastCommitDataSpy).toHaveBeenCalled(); + + done(); + }).catch(done.fail); + }); + }); + + describe('setFileActive', () => { + let scrollToTabSpy; + let oldScrollToTab; + + beforeEach(() => { + scrollToTabSpy = jasmine.createSpy('scrollToTab'); + oldScrollToTab = store._actions.scrollToTab; // eslint-disable-line + store._actions.scrollToTab = [scrollToTabSpy]; // eslint-disable-line + }); + + afterEach(() => { + store._actions.scrollToTab = oldScrollToTab; // eslint-disable-line + }); + + it('calls scrollToTab', (done) => { + store.dispatch('setFileActive', file()) + .then(() => { + expect(scrollToTabSpy).toHaveBeenCalled(); + + done(); + }).catch(done.fail); + }); + + it('sets the file active', (done) => { + const localFile = file(); + + store.dispatch('setFileActive', localFile) + .then(() => { + expect(localFile.active).toBeTruthy(); + + done(); + }).catch(done.fail); + }); + + it('returns early if file is already active', (done) => { + const localFile = file(); + localFile.active = true; + + store.dispatch('setFileActive', localFile) + .then(() => { + expect(scrollToTabSpy).not.toHaveBeenCalled(); + + done(); + }).catch(done.fail); + }); + + it('sets current active file to not active', (done) => { + const localFile = file(); + localFile.active = true; + store.state.openFiles.push(localFile); + + store.dispatch('setFileActive', file()) + .then(() => { + expect(localFile.active).toBeFalsy(); + + done(); + }).catch(done.fail); + }); + + it('resets location.hash for line highlighting', (done) => { + location.hash = 'test'; + + store.dispatch('setFileActive', file()) + .then(() => { + expect(location.hash).not.toBe('test'); + + done(); + }).catch(done.fail); + }); + }); + + describe('getFileData', () => { + let localFile = file(); + + beforeEach(() => { + spyOn(service, 'getFileData').and.returnValue(Promise.resolve({ + headers: { + 'page-title': 'testing getFileData', + }, + json: () => Promise.resolve({ + blame_path: 'blame_path', + commits_path: 'commits_path', + permalink: 'permalink', + raw_path: 'raw_path', + binary: false, + html: '123', + render_error: '', + }), + })); + + localFile = file(); + localFile.url = 'getFileDataURL'; + }); + + it('calls the service', (done) => { + store.dispatch('getFileData', localFile) + .then(() => { + expect(service.getFileData).toHaveBeenCalledWith('getFileDataURL'); + + done(); + }).catch(done.fail); + }); + + it('sets the file data', (done) => { + store.dispatch('getFileData', localFile) + .then(Vue.nextTick) + .then(() => { + expect(localFile.blamePath).toBe('blame_path'); + + done(); + }).catch(done.fail); + }); + + it('sets document title', (done) => { + store.dispatch('getFileData', localFile) + .then(() => { + expect(document.title).toBe('testing getFileData'); + + done(); + }).catch(done.fail); + }); + + it('sets the file as active', (done) => { + store.dispatch('getFileData', localFile) + .then(Vue.nextTick) + .then(() => { + expect(localFile.active).toBeTruthy(); + + done(); + }).catch(done.fail); + }); + + it('adds the file to open files', (done) => { + store.dispatch('getFileData', localFile) + .then(Vue.nextTick) + .then(() => { + expect(store.state.openFiles.length).toBe(1); + expect(store.state.openFiles[0].name).toBe(localFile.name); + + done(); + }).catch(done.fail); + }); + + it('toggles the file loading', (done) => { + store.dispatch('getFileData', localFile) + .then(() => { + expect(localFile.loading).toBeTruthy(); + + return Vue.nextTick(); + }) + .then(() => { + expect(localFile.loading).toBeFalsy(); + + done(); + }).catch(done.fail); + }); + }); + + describe('getRawFileData', () => { + let tmpFile; + + beforeEach(() => { + spyOn(service, 'getRawFileData').and.returnValue(Promise.resolve('raw')); + + tmpFile = file(); + }); + + it('calls getRawFileData service method', (done) => { + store.dispatch('getRawFileData', tmpFile) + .then(() => { + expect(service.getRawFileData).toHaveBeenCalledWith(tmpFile); + + done(); + }).catch(done.fail); + }); + + it('updates file raw data', (done) => { + store.dispatch('getRawFileData', tmpFile) + .then(() => { + expect(tmpFile.raw).toBe('raw'); + + done(); + }).catch(done.fail); + }); + }); + + describe('changeFileContent', () => { + let tmpFile; + + beforeEach(() => { + tmpFile = file(); + }); + + it('updates file content', (done) => { + store.dispatch('changeFileContent', { + file: tmpFile, + content: 'content', + }) + .then(() => { + expect(tmpFile.content).toBe('content'); + + done(); + }).catch(done.fail); + }); + }); + + describe('createTempFile', () => { + beforeEach(() => { + document.body.innerHTML += '<div class="flash-container"></div>'; + }); + + afterEach(() => { + document.querySelector('.flash-container').remove(); + }); + + it('creates temp file', (done) => { + store.dispatch('createTempFile', { + tree: store.state, + name: 'test', + }).then((f) => { + expect(f.tempFile).toBeTruthy(); + expect(store.state.tree.length).toBe(1); + + done(); + }).catch(done.fail); + }); + + it('adds tmp file to open files', (done) => { + store.dispatch('createTempFile', { + tree: store.state, + name: 'test', + }).then((f) => { + expect(store.state.openFiles.length).toBe(1); + expect(store.state.openFiles[0].name).toBe(f.name); + + done(); + }).catch(done.fail); + }); + + it('sets tmp file as active', (done) => { + store.dispatch('createTempFile', { + tree: store.state, + name: 'test', + }).then((f) => { + expect(f.active).toBeTruthy(); + + done(); + }).catch(done.fail); + }); + + it('enters edit mode if file is not base64', (done) => { + store.dispatch('createTempFile', { + tree: store.state, + name: 'test', + }).then(() => { + expect(store.state.editMode).toBeTruthy(); + + done(); + }).catch(done.fail); + }); + + it('does not enter edit mode if file is base64', (done) => { + store.dispatch('createTempFile', { + tree: store.state, + name: 'test', + base64: true, + }).then(() => { + expect(store.state.editMode).toBeFalsy(); + + done(); + }).catch(done.fail); + }); + + it('creates flash message is file already exists', (done) => { + store.state.tree.push(file('test', '1', 'blob')); + + store.dispatch('createTempFile', { + tree: store.state, + name: 'test', + }).then(() => { + expect(document.querySelector('.flash-alert')).not.toBeNull(); + + done(); + }).catch(done.fail); + }); + + it('increases level of file', (done) => { + store.state.level = 1; + + store.dispatch('createTempFile', { + tree: store.state, + name: 'test', + }).then((f) => { + expect(f.level).toBe(2); + + done(); + }).catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/repo/stores/actions/tree_spec.js b/spec/javascripts/repo/stores/actions/tree_spec.js new file mode 100644 index 00000000000..393a797c6a3 --- /dev/null +++ b/spec/javascripts/repo/stores/actions/tree_spec.js @@ -0,0 +1,469 @@ +import Vue from 'vue'; +import store from '~/repo/stores'; +import service from '~/repo/services'; +import { file, resetStore } from '../../helpers'; + +describe('Multi-file store tree actions', () => { + afterEach(() => { + resetStore(store); + }); + + describe('getTreeData', () => { + beforeEach(() => { + spyOn(service, 'getTreeData').and.returnValue(Promise.resolve({ + headers: { + 'page-title': 'test', + }, + json: () => Promise.resolve({ + last_commit_path: 'last_commit_path', + parent_tree_url: 'parent_tree_url', + path: '/', + trees: [{ name: 'tree' }], + blobs: [{ name: 'blob' }], + submodules: [{ name: 'submodule' }], + }), + })); + spyOn(history, 'pushState'); + + Object.assign(store.state.endpoints, { + rootEndpoint: 'rootEndpoint', + }); + }); + + it('calls service getTreeData', (done) => { + store.dispatch('getTreeData') + .then(() => { + expect(service.getTreeData).toHaveBeenCalledWith('rootEndpoint'); + + done(); + }).catch(done.fail); + }); + + it('adds data into tree', (done) => { + store.dispatch('getTreeData') + .then(Vue.nextTick) + .then(() => { + expect(store.state.tree.length).toBe(3); + expect(store.state.tree[0].type).toBe('tree'); + expect(store.state.tree[1].type).toBe('submodule'); + expect(store.state.tree[2].type).toBe('blob'); + + done(); + }).catch(done.fail); + }); + + it('sets parent tree URL', (done) => { + store.dispatch('getTreeData') + .then(Vue.nextTick) + .then(() => { + expect(store.state.parentTreeUrl).toBe('parent_tree_url'); + + done(); + }).catch(done.fail); + }); + + it('sets last commit path', (done) => { + store.dispatch('getTreeData') + .then(Vue.nextTick) + .then(() => { + expect(store.state.lastCommitPath).toBe('last_commit_path'); + + done(); + }).catch(done.fail); + }); + + it('sets root if not currently at root', (done) => { + store.state.isInitialRoot = false; + + store.dispatch('getTreeData') + .then(Vue.nextTick) + .then(() => { + expect(store.state.isInitialRoot).toBeTruthy(); + expect(store.state.isRoot).toBeTruthy(); + + done(); + }).catch(done.fail); + }); + + it('sets page title', (done) => { + store.dispatch('getTreeData') + .then(() => { + expect(document.title).toBe('test'); + + done(); + }).catch(done.fail); + }); + + it('toggles loading', (done) => { + store.dispatch('getTreeData') + .then(() => { + expect(store.state.loading).toBeTruthy(); + + return Vue.nextTick(); + }) + .then(() => { + expect(store.state.loading).toBeFalsy(); + + done(); + }).catch(done.fail); + }); + + it('calls pushState with endpoint', (done) => { + store.dispatch('getTreeData') + .then(Vue.nextTick) + .then(() => { + expect(history.pushState).toHaveBeenCalledWith(jasmine.anything(), '', 'rootEndpoint'); + + done(); + }).catch(done.fail); + }); + + it('calls getLastCommitData if prevLastCommitPath is not null', (done) => { + const getLastCommitDataSpy = jasmine.createSpy('getLastCommitData'); + const oldGetLastCommitData = store._actions.getLastCommitData; // eslint-disable-line + store._actions.getLastCommitData = [getLastCommitDataSpy]; // eslint-disable-line + store.state.prevLastCommitPath = 'test'; + + store.dispatch('getTreeData') + .then(Vue.nextTick) + .then(() => { + expect(getLastCommitDataSpy).toHaveBeenCalledWith(store.state); + + store._actions.getLastCommitData = oldGetLastCommitData; // eslint-disable-line + + done(); + }).catch(done.fail); + }); + }); + + describe('toggleTreeOpen', () => { + let oldGetTreeData; + let getTreeDataSpy; + let tree; + + beforeEach(() => { + getTreeDataSpy = jasmine.createSpy('getTreeData'); + + oldGetTreeData = store._actions.getTreeData; // eslint-disable-line + store._actions.getTreeData = [getTreeDataSpy]; // eslint-disable-line + + tree = { + opened: false, + tree: [], + }; + }); + + afterEach(() => { + store._actions.getTreeData = oldGetTreeData; // eslint-disable-line + }); + + it('toggles the tree open', (done) => { + store.dispatch('toggleTreeOpen', { + endpoint: 'test', + tree, + }).then(() => { + expect(tree.opened).toBeTruthy(); + + done(); + }).catch(done.fail); + }); + + it('calls getTreeData if tree is closed', (done) => { + store.dispatch('toggleTreeOpen', { + endpoint: 'test', + tree, + }).then(() => { + expect(getTreeDataSpy).toHaveBeenCalledWith({ + endpoint: 'test', + tree, + }); + expect(store.state.previousUrl).toBe('test'); + + done(); + }).catch(done.fail); + }); + + it('resets entries tree', (done) => { + Object.assign(tree, { + opened: true, + tree: ['a'], + }); + + store.dispatch('toggleTreeOpen', { + endpoint: 'test', + tree, + }).then(() => { + expect(tree.tree.length).toBe(0); + + done(); + }).catch(done.fail); + }); + + it('pushes new state', (done) => { + spyOn(history, 'pushState'); + Object.assign(tree, { + opened: true, + parentTreeUrl: 'testing', + }); + + store.dispatch('toggleTreeOpen', { + endpoint: 'test', + tree, + }).then(() => { + expect(history.pushState).toHaveBeenCalledWith(jasmine.anything(), '', 'testing'); + + done(); + }).catch(done.fail); + }); + }); + + describe('clickedTreeRow', () => { + describe('tree', () => { + let toggleTreeOpenSpy; + let oldToggleTreeOpen; + + beforeEach(() => { + toggleTreeOpenSpy = jasmine.createSpy('toggleTreeOpen'); + + oldToggleTreeOpen = store._actions.toggleTreeOpen; // eslint-disable-line + store._actions.toggleTreeOpen = [toggleTreeOpenSpy]; // eslint-disable-line + }); + + afterEach(() => { + store._actions.toggleTreeOpen = oldToggleTreeOpen; // eslint-disable-line + }); + + it('opens tree', (done) => { + const tree = { + url: 'a', + type: 'tree', + }; + + store.dispatch('clickedTreeRow', tree) + .then(() => { + expect(toggleTreeOpenSpy).toHaveBeenCalledWith({ + endpoint: tree.url, + tree, + }); + + done(); + }).catch(done.fail); + }); + }); + + describe('submodule', () => { + let row; + + beforeEach(() => { + spyOn(gl.utils, 'visitUrl'); + + row = { + url: 'submoduleurl', + type: 'submodule', + loading: false, + }; + }); + + it('toggles loading for row', (done) => { + store.dispatch('clickedTreeRow', row) + .then(() => { + expect(row.loading).toBeTruthy(); + + done(); + }).catch(done.fail); + }); + + it('opens submodule URL', (done) => { + store.dispatch('clickedTreeRow', row) + .then(() => { + expect(gl.utils.visitUrl).toHaveBeenCalledWith('submoduleurl'); + + done(); + }).catch(done.fail); + }); + }); + + describe('blob', () => { + let row; + + beforeEach(() => { + row = { + type: 'blob', + opened: false, + }; + }); + + it('calls getFileData', (done) => { + const getFileDataSpy = jasmine.createSpy('getFileData'); + const oldGetFileData = store._actions.getFileData; // eslint-disable-line + store._actions.getFileData = [getFileDataSpy]; // eslint-disable-line + + store.dispatch('clickedTreeRow', row) + .then(() => { + expect(getFileDataSpy).toHaveBeenCalledWith(row); + + store._actions.getFileData = oldGetFileData; // eslint-disable-line + + done(); + }).catch(done.fail); + }); + + it('calls setFileActive when file is opened', (done) => { + const setFileActiveSpy = jasmine.createSpy('setFileActive'); + const oldSetFileActive = store._actions.setFileActive; // eslint-disable-line + store._actions.setFileActive = [setFileActiveSpy]; // eslint-disable-line + + row.opened = true; + + store.dispatch('clickedTreeRow', row) + .then(() => { + expect(setFileActiveSpy).toHaveBeenCalledWith(row); + + store._actions.setFileActive = oldSetFileActive; // eslint-disable-line + + done(); + }).catch(done.fail); + }); + }); + }); + + describe('createTempTree', () => { + it('creates temp tree', (done) => { + store.dispatch('createTempTree', 'test') + .then(() => { + expect(store.state.tree[0].tempFile).toBeTruthy(); + expect(store.state.tree[0].name).toBe('test'); + expect(store.state.tree[0].type).toBe('tree'); + + done(); + }).catch(done.fail); + }); + + it('creates .gitkeep file in temp tree', (done) => { + store.dispatch('createTempTree', 'test') + .then(() => { + expect(store.state.tree[0].tree[0].tempFile).toBeTruthy(); + expect(store.state.tree[0].tree[0].name).toBe('.gitkeep'); + + done(); + }).catch(done.fail); + }); + + it('creates new folder inside another tree', (done) => { + const tree = { + type: 'tree', + name: 'testing', + tree: [], + }; + + store.state.tree.push(tree); + + store.dispatch('createTempTree', 'testing/test') + .then(() => { + expect(store.state.tree[0].name).toBe('testing'); + expect(store.state.tree[0].tree[0].tempFile).toBeTruthy(); + expect(store.state.tree[0].tree[0].name).toBe('test'); + expect(store.state.tree[0].tree[0].type).toBe('tree'); + + done(); + }).catch(done.fail); + }); + + it('does not create new tree if already exists', (done) => { + const tree = { + type: 'tree', + name: 'testing', + tree: [], + }; + + store.state.tree.push(tree); + + store.dispatch('createTempTree', 'testing/test') + .then(() => { + expect(store.state.tree[0].name).toBe('testing'); + expect(store.state.tree[0].tempFile).toBeUndefined(); + + done(); + }).catch(done.fail); + }); + }); + + describe('getLastCommitData', () => { + beforeEach(() => { + spyOn(service, 'getTreeLastCommit').and.returnValue(Promise.resolve({ + headers: { + 'more-logs-url': null, + }, + json: () => Promise.resolve([{ + type: 'tree', + file_name: 'testing', + commit: { + message: 'commit message', + authored_date: '123', + }, + }]), + })); + + store.state.tree.push(file('testing', '1', 'tree')); + store.state.lastCommitPath = 'lastcommitpath'; + }); + + it('calls service with lastCommitPath', (done) => { + store.dispatch('getLastCommitData') + .then(() => { + expect(service.getTreeLastCommit).toHaveBeenCalledWith('lastcommitpath'); + + done(); + }).catch(done.fail); + }); + + it('updates trees last commit data', (done) => { + store.dispatch('getLastCommitData') + .then(Vue.nextTick) + .then(() => { + expect(store.state.tree[0].lastCommit.message).toBe('commit message'); + + done(); + }).catch(done.fail); + }); + + it('does not update entry if not found', (done) => { + store.state.tree[0].name = 'a'; + + store.dispatch('getLastCommitData') + .then(Vue.nextTick) + .then(() => { + expect(store.state.tree[0].lastCommit.message).not.toBe('commit message'); + + done(); + }).catch(done.fail); + }); + }); + + describe('updateDirectoryData', () => { + it('adds data into tree', (done) => { + const tree = { + tree: [], + }; + const data = { + trees: [{ name: 'tree' }], + submodules: [{ name: 'submodule' }], + blobs: [{ name: 'blob' }], + }; + + store.dispatch('updateDirectoryData', { + data, + tree, + }).then(() => { + expect(tree.tree[0].name).toBe('tree'); + expect(tree.tree[0].type).toBe('tree'); + expect(tree.tree[1].name).toBe('submodule'); + expect(tree.tree[1].type).toBe('submodule'); + expect(tree.tree[2].name).toBe('blob'); + expect(tree.tree[2].type).toBe('blob'); + + done(); + }).catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/repo/stores/actions_spec.js b/spec/javascripts/repo/stores/actions_spec.js new file mode 100644 index 00000000000..f2a7a698912 --- /dev/null +++ b/spec/javascripts/repo/stores/actions_spec.js @@ -0,0 +1,419 @@ +import Vue from 'vue'; +import store from '~/repo/stores'; +import service from '~/repo/services'; +import { resetStore, file } from '../helpers'; + +describe('Multi-file store actions', () => { + afterEach(() => { + resetStore(store); + }); + + describe('redirectToUrl', () => { + it('calls visitUrl', (done) => { + spyOn(gl.utils, 'visitUrl'); + + store.dispatch('redirectToUrl', 'test') + .then(() => { + expect(gl.utils.visitUrl).toHaveBeenCalledWith('test'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('setInitialData', () => { + it('commits initial data', (done) => { + store.dispatch('setInitialData', { canCommit: true }) + .then(() => { + expect(store.state.canCommit).toBeTruthy(); + done(); + }) + .catch(done.fail); + }); + }); + + describe('closeDiscardPopup', () => { + it('closes the discard popup', (done) => { + store.dispatch('closeDiscardPopup', false) + .then(() => { + expect(store.state.discardPopupOpen).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('discardAllChanges', () => { + beforeEach(() => { + store.state.openFiles.push(file()); + store.state.openFiles[0].changed = true; + }); + }); + + describe('closeAllFiles', () => { + beforeEach(() => { + store.state.openFiles.push(file()); + store.state.openFiles[0].opened = true; + }); + + it('closes all open files', (done) => { + store.dispatch('closeAllFiles') + .then(() => { + expect(store.state.openFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('toggleEditMode', () => { + it('toggles edit mode', (done) => { + store.state.editMode = true; + + store.dispatch('toggleEditMode') + .then(() => { + expect(store.state.editMode).toBeFalsy(); + + done(); + }).catch(done.fail); + }); + + it('sets preview mode', (done) => { + store.state.currentBlobView = 'repo-editor'; + store.state.editMode = true; + + store.dispatch('toggleEditMode') + .then(Vue.nextTick) + .then(() => { + expect(store.state.currentBlobView).toBe('repo-preview'); + + done(); + }).catch(done.fail); + }); + + it('opens discard popup if there are changed files', (done) => { + store.state.editMode = true; + store.state.openFiles.push(file()); + store.state.openFiles[0].changed = true; + + store.dispatch('toggleEditMode') + .then(() => { + expect(store.state.discardPopupOpen).toBeTruthy(); + + done(); + }).catch(done.fail); + }); + + it('can force closed if there are changed files', (done) => { + store.state.editMode = true; + store.state.openFiles.push(file()); + store.state.openFiles[0].changed = true; + + store.dispatch('toggleEditMode', true) + .then(() => { + expect(store.state.discardPopupOpen).toBeFalsy(); + expect(store.state.editMode).toBeFalsy(); + + done(); + }).catch(done.fail); + }); + + it('discards file changes', (done) => { + const f = file(); + store.state.editMode = true; + store.state.tree.push(f); + store.state.openFiles.push(f); + f.changed = true; + + store.dispatch('toggleEditMode', true) + .then(Vue.nextTick) + .then(() => { + expect(f.changed).toBeFalsy(); + + done(); + }).catch(done.fail); + }); + }); + + describe('toggleBlobView', () => { + it('sets edit mode view if in edit mode', (done) => { + store.state.editMode = true; + + store.dispatch('toggleBlobView') + .then(() => { + expect(store.state.currentBlobView).toBe('repo-editor'); + + done(); + }) + .catch(done.fail); + }); + + it('sets preview mode view if not in edit mode', (done) => { + store.dispatch('toggleBlobView') + .then(() => { + expect(store.state.currentBlobView).toBe('repo-preview'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('checkCommitStatus', () => { + beforeEach(() => { + store.state.project.id = 2; + store.state.currentBranch = 'master'; + store.state.currentRef = '1'; + }); + + it('calls service', (done) => { + spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({ + commit: { id: '123' }, + })); + + store.dispatch('checkCommitStatus') + .then(() => { + expect(service.getBranchData).toHaveBeenCalledWith(2, 'master'); + + done(); + }) + .catch(done.fail); + }); + + it('returns true if current ref does not equal returned ID', (done) => { + spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({ + commit: { id: '123' }, + })); + + store.dispatch('checkCommitStatus') + .then((val) => { + expect(val).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('returns false if current ref equals returned ID', (done) => { + spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({ + commit: { id: '1' }, + })); + + store.dispatch('checkCommitStatus') + .then((val) => { + expect(val).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('commitChanges', () => { + let payload; + + beforeEach(() => { + spyOn(window, 'scrollTo'); + + document.body.innerHTML += '<div class="flash-container"></div>'; + + store.state.project.id = 123; + payload = { + branch: 'master', + }; + }); + + afterEach(() => { + document.querySelector('.flash-container').remove(); + }); + + describe('success', () => { + beforeEach(() => { + spyOn(service, 'commit').and.returnValue(Promise.resolve({ + id: '123456', + short_id: '123', + message: 'test message', + committed_date: 'date', + stats: { + additions: '1', + deletions: '2', + }, + })); + }); + + it('calls service', (done) => { + store.dispatch('commitChanges', { payload, newMr: false }) + .then(() => { + expect(service.commit).toHaveBeenCalledWith(123, payload); + + done(); + }).catch(done.fail); + }); + + it('shows flash notice', (done) => { + store.dispatch('commitChanges', { payload, newMr: false }) + .then(() => { + const alert = document.querySelector('.flash-container'); + + expect(alert.querySelector('.flash-notice')).not.toBeNull(); + expect(alert.textContent.trim()).toBe( + 'Your changes have been committed. Commit 123 with 1 additions, 2 deletions.', + ); + + done(); + }).catch(done.fail); + }); + + it('adds commit data to changed files', (done) => { + const changedFile = file(); + const f = file(); + changedFile.changed = true; + + store.state.openFiles.push(changedFile, f); + + store.dispatch('commitChanges', { payload, newMr: false }) + .then(() => { + expect(changedFile.lastCommit.message).toBe('test message'); + expect(f.lastCommit.message).not.toBe('test message'); + + done(); + }).catch(done.fail); + }); + + it('toggles edit mode', (done) => { + store.state.editMode = true; + + store.dispatch('commitChanges', { payload, newMr: false }) + .then(() => { + expect(store.state.editMode).toBeFalsy(); + + done(); + }).catch(done.fail); + }); + + it('closes all files', (done) => { + store.state.openFiles.push(file()); + store.state.openFiles[0].opened = true; + + store.dispatch('commitChanges', { payload, newMr: false }) + .then(Vue.nextTick) + .then(() => { + expect(store.state.openFiles.length).toBe(0); + + done(); + }).catch(done.fail); + }); + + it('scrolls to top of page', (done) => { + store.dispatch('commitChanges', { payload, newMr: false }) + .then(() => { + expect(window.scrollTo).toHaveBeenCalledWith(0, 0); + + done(); + }).catch(done.fail); + }); + + it('updates commit ref', (done) => { + store.dispatch('commitChanges', { payload, newMr: false }) + .then(() => { + expect(store.state.currentRef).toBe('123456'); + + done(); + }).catch(done.fail); + }); + + it('redirects to new merge request page', (done) => { + spyOn(gl.utils, 'visitUrl'); + + store.state.endpoints.newMergeRequestUrl = 'newMergeRequestUrl?branch='; + + store.dispatch('commitChanges', { payload, newMr: true }) + .then(() => { + expect(gl.utils.visitUrl).toHaveBeenCalledWith('newMergeRequestUrl?branch=master'); + + done(); + }).catch(done.fail); + }); + }); + + describe('failed', () => { + beforeEach(() => { + spyOn(service, 'commit').and.returnValue(Promise.resolve({ + message: 'failed message', + })); + }); + + it('shows failed message', (done) => { + store.dispatch('commitChanges', { payload, newMr: false }) + .then(() => { + const alert = document.querySelector('.flash-container'); + + expect(alert.textContent.trim()).toBe( + 'failed message', + ); + + done(); + }).catch(done.fail); + }); + }); + }); + + describe('createTempEntry', () => { + it('creates a temp tree', (done) => { + store.dispatch('createTempEntry', { + name: 'test', + type: 'tree', + }) + .then(() => { + expect(store.state.tree.length).toBe(1); + expect(store.state.tree[0].tempFile).toBeTruthy(); + expect(store.state.tree[0].type).toBe('tree'); + + done(); + }) + .catch(done.fail); + }); + + it('creates temp file', (done) => { + store.dispatch('createTempEntry', { + name: 'test', + type: 'blob', + }) + .then(() => { + expect(store.state.tree.length).toBe(1); + expect(store.state.tree[0].tempFile).toBeTruthy(); + expect(store.state.tree[0].type).toBe('blob'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('popHistoryState', () => { + + }); + + describe('scrollToTab', () => { + it('focuses the current active element', (done) => { + document.body.innerHTML += '<div id="tabs"><div class="active"><div class="repo-tab"></div></div></div>'; + const el = document.querySelector('.repo-tab'); + spyOn(el, 'focus'); + + store.dispatch('scrollToTab') + .then(() => { + setTimeout(() => { + expect(el.focus).toHaveBeenCalled(); + + document.getElementById('tabs').remove(); + + done(); + }); + }) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/repo/stores/getters_spec.js b/spec/javascripts/repo/stores/getters_spec.js new file mode 100644 index 00000000000..a204b2386cd --- /dev/null +++ b/spec/javascripts/repo/stores/getters_spec.js @@ -0,0 +1,119 @@ +import * as getters from '~/repo/stores/getters'; +import state from '~/repo/stores/state'; +import { file } from '../helpers'; + +describe('Multi-file store getters', () => { + let localState; + + beforeEach(() => { + localState = state(); + }); + + describe('treeList', () => { + it('returns flat tree list', () => { + localState.tree.push(file('1')); + localState.tree[0].tree.push(file('2')); + localState.tree[0].tree[0].tree.push(file('3')); + + const treeList = getters.treeList(localState); + + expect(treeList.length).toBe(3); + expect(treeList[1].name).toBe(localState.tree[0].tree[0].name); + expect(treeList[2].name).toBe(localState.tree[0].tree[0].tree[0].name); + }); + }); + + describe('changedFiles', () => { + it('returns a list of changed opened files', () => { + localState.openFiles.push(file()); + localState.openFiles.push(file('changed')); + localState.openFiles[1].changed = true; + + const changedFiles = getters.changedFiles(localState); + + expect(changedFiles.length).toBe(1); + expect(changedFiles[0].name).toBe('changed'); + }); + }); + + describe('activeFile', () => { + it('returns the current active file', () => { + localState.openFiles.push(file()); + localState.openFiles.push(file('active')); + localState.openFiles[1].active = true; + + expect(getters.activeFile(localState).name).toBe('active'); + }); + + it('returns undefined if no active files are found', () => { + localState.openFiles.push(file()); + localState.openFiles.push(file('active')); + + expect(getters.activeFile(localState)).toBeUndefined(); + }); + }); + + describe('activeFileExtension', () => { + it('returns the file extension for the current active file', () => { + localState.openFiles.push(file('active')); + localState.openFiles[0].active = true; + localState.openFiles[0].path = 'test.js'; + + expect(getters.activeFileExtension(localState)).toBe('.js'); + + localState.openFiles[0].path = 'test.es6.js'; + + expect(getters.activeFileExtension(localState)).toBe('.js'); + }); + }); + + describe('isCollapsed', () => { + it('returns true if state has open files', () => { + localState.openFiles.push(file()); + + expect(getters.isCollapsed(localState)).toBeTruthy(); + }); + + it('returns false if state has no open files', () => { + expect(getters.isCollapsed(localState)).toBeFalsy(); + }); + }); + + describe('canEditFile', () => { + beforeEach(() => { + localState.onTopOfBranch = true; + localState.canCommit = true; + + localState.openFiles.push(file()); + localState.openFiles[0].active = true; + }); + + it('returns true if user can commit and has open files', () => { + expect(getters.canEditFile(localState)).toBeTruthy(); + }); + + it('returns false if user can commit and has no open files', () => { + localState.openFiles = []; + + expect(getters.canEditFile(localState)).toBeFalsy(); + }); + + it('returns false if user can commit and active file is binary', () => { + localState.openFiles[0].binary = true; + + expect(getters.canEditFile(localState)).toBeFalsy(); + }); + + it('returns false if user cant commit', () => { + localState.canCommit = false; + + expect(getters.canEditFile(localState)).toBeFalsy(); + }); + + it('returns false if user can commit but on a branch', () => { + localState.onTopOfBranch = false; + + expect(getters.canEditFile(localState)).toBeFalsy(); + }); + }); +}); diff --git a/spec/javascripts/repo/stores/mutations/branch_spec.js b/spec/javascripts/repo/stores/mutations/branch_spec.js new file mode 100644 index 00000000000..3c06794d5e3 --- /dev/null +++ b/spec/javascripts/repo/stores/mutations/branch_spec.js @@ -0,0 +1,18 @@ +import mutations from '~/repo/stores/mutations/branch'; +import state from '~/repo/stores/state'; + +describe('Multi-file store branch mutations', () => { + let localState; + + beforeEach(() => { + localState = state(); + }); + + describe('SET_CURRENT_BRANCH', () => { + it('sets currentBranch', () => { + mutations.SET_CURRENT_BRANCH(localState, 'master'); + + expect(localState.currentBranch).toBe('master'); + }); + }); +}); diff --git a/spec/javascripts/repo/stores/mutations/file_spec.js b/spec/javascripts/repo/stores/mutations/file_spec.js new file mode 100644 index 00000000000..2f2835dde1f --- /dev/null +++ b/spec/javascripts/repo/stores/mutations/file_spec.js @@ -0,0 +1,131 @@ +import mutations from '~/repo/stores/mutations/file'; +import state from '~/repo/stores/state'; +import { file } from '../../helpers'; + +describe('Multi-file store file mutations', () => { + let localState; + let localFile; + + beforeEach(() => { + localState = state(); + localFile = file(); + }); + + describe('SET_FILE_ACTIVE', () => { + it('sets the file active', () => { + mutations.SET_FILE_ACTIVE(localState, { + file: localFile, + active: true, + }); + + expect(localFile.active).toBeTruthy(); + }); + }); + + describe('TOGGLE_FILE_OPEN', () => { + beforeEach(() => { + mutations.TOGGLE_FILE_OPEN(localState, localFile); + }); + + it('adds into opened files', () => { + expect(localFile.opened).toBeTruthy(); + expect(localState.openFiles.length).toBe(1); + }); + + it('removes from opened files', () => { + mutations.TOGGLE_FILE_OPEN(localState, localFile); + + expect(localFile.opened).toBeFalsy(); + expect(localState.openFiles.length).toBe(0); + }); + }); + + describe('SET_FILE_DATA', () => { + it('sets extra file data', () => { + mutations.SET_FILE_DATA(localState, { + data: { + blame_path: 'blame', + commits_path: 'commits', + permalink: 'permalink', + raw_path: 'raw', + binary: true, + html: 'html', + render_error: 'render_error', + }, + file: localFile, + }); + + expect(localFile.blamePath).toBe('blame'); + expect(localFile.commitsPath).toBe('commits'); + expect(localFile.permalink).toBe('permalink'); + expect(localFile.rawPath).toBe('raw'); + expect(localFile.binary).toBeTruthy(); + expect(localFile.html).toBe('html'); + expect(localFile.renderError).toBe('render_error'); + }); + }); + + describe('SET_FILE_RAW_DATA', () => { + it('sets raw data', () => { + mutations.SET_FILE_RAW_DATA(localState, { + file: localFile, + raw: 'testing', + }); + + expect(localFile.raw).toBe('testing'); + }); + }); + + describe('UPDATE_FILE_CONTENT', () => { + beforeEach(() => { + localFile.raw = 'test'; + }); + + it('sets content', () => { + mutations.UPDATE_FILE_CONTENT(localState, { + file: localFile, + content: 'test', + }); + + expect(localFile.content).toBe('test'); + }); + + it('sets changed if content does not match raw', () => { + mutations.UPDATE_FILE_CONTENT(localState, { + file: localFile, + content: 'testing', + }); + + expect(localFile.content).toBe('testing'); + expect(localFile.changed).toBeTruthy(); + }); + }); + + describe('DISCARD_FILE_CHANGES', () => { + beforeEach(() => { + localFile.content = 'test'; + localFile.changed = true; + }); + + it('resets content and changed', () => { + mutations.DISCARD_FILE_CHANGES(localState, localFile); + + expect(localFile.content).toBe(''); + expect(localFile.changed).toBeFalsy(); + }); + }); + + describe('CREATE_TMP_FILE', () => { + it('adds file into parent tree', () => { + const f = file(); + + mutations.CREATE_TMP_FILE(localState, { + file: f, + parent: localFile, + }); + + expect(localFile.tree.length).toBe(1); + expect(localFile.tree[0].name).toBe(f.name); + }); + }); +}); diff --git a/spec/javascripts/repo/stores/mutations/tree_spec.js b/spec/javascripts/repo/stores/mutations/tree_spec.js new file mode 100644 index 00000000000..1c76cfed9c8 --- /dev/null +++ b/spec/javascripts/repo/stores/mutations/tree_spec.js @@ -0,0 +1,71 @@ +import mutations from '~/repo/stores/mutations/tree'; +import state from '~/repo/stores/state'; +import { file } from '../../helpers'; + +describe('Multi-file store tree mutations', () => { + let localState; + let localTree; + + beforeEach(() => { + localState = state(); + localTree = file(); + }); + + describe('TOGGLE_TREE_OPEN', () => { + it('toggles tree open', () => { + mutations.TOGGLE_TREE_OPEN(localState, localTree); + + expect(localTree.opened).toBeTruthy(); + + mutations.TOGGLE_TREE_OPEN(localState, localTree); + + expect(localTree.opened).toBeFalsy(); + }); + }); + + describe('SET_DIRECTORY_DATA', () => { + const data = [{ + name: 'tree', + }, + { + name: 'submodule', + }, + { + name: 'blob', + }]; + + it('adds directory data', () => { + mutations.SET_DIRECTORY_DATA(localState, { + data, + tree: localState, + }); + + expect(localState.tree.length).toBe(3); + expect(localState.tree[0].name).toBe('tree'); + expect(localState.tree[1].name).toBe('submodule'); + expect(localState.tree[2].name).toBe('blob'); + }); + }); + + describe('SET_PARENT_TREE_URL', () => { + it('sets the parent tree url', () => { + mutations.SET_PARENT_TREE_URL(localState, 'test'); + + expect(localState.parentTreeUrl).toBe('test'); + }); + }); + + describe('CREATE_TMP_TREE', () => { + it('adds tree into parent tree', () => { + const tmpEntry = file(); + + mutations.CREATE_TMP_TREE(localState, { + tmpEntry, + parent: localTree, + }); + + expect(localTree.tree.length).toBe(1); + expect(localTree.tree[0].name).toBe(tmpEntry.name); + }); + }); +}); diff --git a/spec/javascripts/repo/stores/mutations_spec.js b/spec/javascripts/repo/stores/mutations_spec.js new file mode 100644 index 00000000000..d1c9885e01d --- /dev/null +++ b/spec/javascripts/repo/stores/mutations_spec.js @@ -0,0 +1,117 @@ +import mutations from '~/repo/stores/mutations'; +import state from '~/repo/stores/state'; +import { file } from '../helpers'; + +describe('Multi-file store mutations', () => { + let localState; + let entry; + + beforeEach(() => { + localState = state(); + entry = file(); + }); + + describe('SET_INITIAL_DATA', () => { + it('sets all initial data', () => { + mutations.SET_INITIAL_DATA(localState, { + test: 'test', + }); + + expect(localState.test).toBe('test'); + }); + }); + + describe('SET_PREVIEW_MODE', () => { + it('sets currentBlobView to repo-preview', () => { + mutations.SET_PREVIEW_MODE(localState); + + expect(localState.currentBlobView).toBe('repo-preview'); + + localState.currentBlobView = 'testing'; + + mutations.SET_PREVIEW_MODE(localState); + + expect(localState.currentBlobView).toBe('repo-preview'); + }); + }); + + describe('SET_EDIT_MODE', () => { + it('sets currentBlobView to repo-editor', () => { + mutations.SET_EDIT_MODE(localState); + + expect(localState.currentBlobView).toBe('repo-editor'); + + localState.currentBlobView = 'testing'; + + mutations.SET_EDIT_MODE(localState); + + expect(localState.currentBlobView).toBe('repo-editor'); + }); + }); + + describe('TOGGLE_LOADING', () => { + it('toggles loading of entry', () => { + mutations.TOGGLE_LOADING(localState, entry); + + expect(entry.loading).toBeTruthy(); + + mutations.TOGGLE_LOADING(localState, entry); + + expect(entry.loading).toBeFalsy(); + }); + }); + + describe('TOGGLE_EDIT_MODE', () => { + it('toggles editMode', () => { + mutations.TOGGLE_EDIT_MODE(localState); + + expect(localState.editMode).toBeTruthy(); + + mutations.TOGGLE_EDIT_MODE(localState); + + expect(localState.editMode).toBeFalsy(); + }); + }); + + describe('TOGGLE_DISCARD_POPUP', () => { + it('sets discardPopupOpen', () => { + mutations.TOGGLE_DISCARD_POPUP(localState, true); + + expect(localState.discardPopupOpen).toBeTruthy(); + + mutations.TOGGLE_DISCARD_POPUP(localState, false); + + expect(localState.discardPopupOpen).toBeFalsy(); + }); + }); + + describe('SET_COMMIT_REF', () => { + it('sets currentRef', () => { + mutations.SET_COMMIT_REF(localState, '123'); + + expect(localState.currentRef).toBe('123'); + }); + }); + + describe('SET_ROOT', () => { + it('sets isRoot & initialRoot', () => { + mutations.SET_ROOT(localState, true); + + expect(localState.isRoot).toBeTruthy(); + expect(localState.isInitialRoot).toBeTruthy(); + + mutations.SET_ROOT(localState, false); + + expect(localState.isRoot).toBeFalsy(); + expect(localState.isInitialRoot).toBeFalsy(); + }); + }); + + describe('SET_PREVIOUS_URL', () => { + it('sets previousUrl', () => { + mutations.SET_PREVIOUS_URL(localState, 'testing'); + + expect(localState.previousUrl).toBe('testing'); + }); + }); +}); diff --git a/spec/javascripts/repo/stores/utils_spec.js b/spec/javascripts/repo/stores/utils_spec.js new file mode 100644 index 00000000000..37287c587d7 --- /dev/null +++ b/spec/javascripts/repo/stores/utils_spec.js @@ -0,0 +1,102 @@ +import * as utils from '~/repo/stores/utils'; + +describe('Multi-file store utils', () => { + describe('setPageTitle', () => { + it('sets the document page title', () => { + utils.setPageTitle('test'); + + expect(document.title).toBe('test'); + }); + }); + + describe('pushState', () => { + it('calls history.pushState', () => { + spyOn(history, 'pushState'); + + utils.pushState('test'); + + expect(history.pushState).toHaveBeenCalledWith({ url: 'test' }, '', 'test'); + }); + }); + + describe('createTemp', () => { + it('creates temp tree', () => { + const tmp = utils.createTemp({ + name: 'test', + path: 'test', + type: 'tree', + level: 0, + changed: false, + content: '', + base64: '', + }); + + expect(tmp.tempFile).toBeTruthy(); + expect(tmp.icon).toBe('fa-folder'); + }); + + it('creates temp file', () => { + const tmp = utils.createTemp({ + name: 'test', + path: 'test', + type: 'blob', + level: 0, + changed: false, + content: '', + base64: '', + }); + + expect(tmp.tempFile).toBeTruthy(); + expect(tmp.icon).toBe('fa-file-text-o'); + }); + }); + + describe('findIndexOfFile', () => { + let state; + + beforeEach(() => { + state = [{ + path: '1', + }, { + path: '2', + }]; + }); + + it('finds in the index of an entry by path', () => { + const index = utils.findIndexOfFile(state, { + path: '2', + }); + + expect(index).toBe(1); + }); + }); + + describe('findEntry', () => { + let state; + + beforeEach(() => { + state = { + tree: [{ + type: 'tree', + name: 'test', + }, { + type: 'blob', + name: 'file', + }], + }; + }); + + it('returns an entry found by name', () => { + const foundEntry = utils.findEntry(state, 'tree', 'test'); + + expect(foundEntry.type).toBe('tree'); + expect(foundEntry.name).toBe('test'); + }); + + it('returns undefined when no entry found', () => { + const foundEntry = utils.findEntry(state, 'blob', 'test'); + + expect(foundEntry).toBeUndefined(); + }); + }); +}); diff --git a/spec/lib/gitlab/utils/strong_memoize_spec.rb b/spec/lib/gitlab/utils/strong_memoize_spec.rb new file mode 100644 index 00000000000..4a104ab6d97 --- /dev/null +++ b/spec/lib/gitlab/utils/strong_memoize_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +describe Gitlab::Utils::StrongMemoize do + let(:klass) do + struct = Struct.new(:value) do + def method_name + strong_memoize(:method_name) do + trace << value + value + end + end + + def trace + @trace ||= [] + end + end + + struct.include(described_class) + struct + end + + subject(:object) { klass.new(value) } + + shared_examples 'caching the value' do + it 'only calls the block once' do + value0 = object.method_name + value1 = object.method_name + + expect(value0).to eq(value) + expect(value1).to eq(value) + expect(object.trace).to contain_exactly(value) + end + + it 'returns and defines the instance variable for the exact value' do + returned_value = object.method_name + memoized_value = object.instance_variable_get(:@method_name) + + expect(returned_value).to eql(value) + expect(memoized_value).to eql(value) + end + end + + describe '#strong_memoize' do + [nil, false, true, 'value', 0, [0]].each do |value| + context "with value #{value}" do + let(:value) { value } + + it_behaves_like 'caching the value' + end + end + end +end diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index c832cee965b..f942a22b6d1 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -783,7 +783,25 @@ describe Notify do shared_examples 'an email for a note on a diff discussion' do |model| let(:note) { create(model, author: note_author) } - it "includes diffs with character-level highlighting" do + context 'when note is on image' do + before do + allow_any_instance_of(DiffDiscussion).to receive(:on_image?).and_return(true) + end + + it 'does not include diffs with character-level highlighting' do + is_expected.not_to have_body_text '<span class="p">}</span></span>' + end + + it 'ends the intro with a dot' do + is_expected.to have_body_text "#{note.diff_file.file_path}</a>." + end + end + + it 'ends the intro with a colon' do + is_expected.to have_body_text "#{note.diff_file.file_path}</a>:" + end + + it 'includes diffs with character-level highlighting' do is_expected.to have_body_text '<span class="p">}</span></span>' end diff --git a/spec/models/concerns/milestoneish_spec.rb b/spec/models/concerns/milestoneish_spec.rb index 66353935427..9048da0c73d 100644 --- a/spec/models/concerns/milestoneish_spec.rb +++ b/spec/models/concerns/milestoneish_spec.rb @@ -186,4 +186,21 @@ describe Milestone, 'Milestoneish' do expect(milestone.elapsed_days).to eq(2) end end + + describe '#total_issue_time_spent' do + it 'calculates total issue time spent' do + closed_issue_1.spend_time(duration: 300, user: author) + closed_issue_1.save! + closed_issue_2.spend_time(duration: 600, user: assignee) + closed_issue_2.save! + + expect(milestone.total_issue_time_spent).to eq(900) + end + end + + describe '#human_total_issue_time_spent' do + it 'returns nil if no time has been spent' do + expect(milestone.human_total_issue_time_spent).to be_nil + end + end end diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index bb5033c1628..5f901262598 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -765,22 +765,4 @@ describe Issue do expect(described_class.public_only).to eq([public_issue]) end end - - describe '#update_project_counter_caches?' do - it 'returns true when the state changes' do - subject.state = 'closed' - - expect(subject.update_project_counter_caches?).to eq(true) - end - - it 'returns true when the confidential flag changes' do - subject.confidential = true - - expect(subject.update_project_counter_caches?).to eq(true) - end - - it 'returns false when the state or confidential flag did not change' do - expect(subject.update_project_counter_caches?).to eq(false) - end - end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index d022dae3476..d250ad50713 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -1772,16 +1772,4 @@ describe MergeRequest do .to change { project.open_merge_requests_count }.from(1).to(0) end end - - describe '#update_project_counter_caches?' do - it 'returns true when the state changes' do - subject.state = 'closed' - - expect(subject.update_project_counter_caches?).to eq(true) - end - - it 'returns false when the state did not change' do - expect(subject.update_project_counter_caches?).to eq(false) - end - end end diff --git a/spec/requests/api/pages_domains_spec.rb b/spec/requests/api/pages_domains_spec.rb index d13b3a958c9..d412b045e9f 100644 --- a/spec/requests/api/pages_domains_spec.rb +++ b/spec/requests/api/pages_domains_spec.rb @@ -3,6 +3,7 @@ require 'rails_helper' describe API::PagesDomains do set(:project) { create(:project) } set(:user) { create(:user) } + set(:admin) { create(:admin) } set(:pages_domain) { create(:pages_domain, domain: 'www.domain.test', project: project) } set(:pages_domain_secure) { create(:pages_domain, :with_certificate, :with_key, domain: 'ssl.domain.test', project: project) } @@ -23,12 +24,49 @@ describe API::PagesDomains do allow(Gitlab.config.pages).to receive(:enabled).and_return(true) end + describe 'GET /pages/domains' do + context 'when pages is disabled' do + before do + allow(Gitlab.config.pages).to receive(:enabled).and_return(false) + end + + it_behaves_like '404 response' do + let(:request) { get api('/pages/domains', admin) } + end + end + + context 'when pages is enabled' do + context 'when authenticated as an admin' do + it 'returns paginated all pages domains' do + get api('/pages/domains', admin) + + expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/pages_domain_basics') + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(3) + expect(json_response.last).to have_key('domain') + expect(json_response.last).to have_key('certificate_expiration') + expect(json_response.last['certificate_expiration']['expired']).to be true + expect(json_response.first).not_to have_key('certificate_expiration') + end + end + + context 'when authenticated as a non-member' do + it_behaves_like '403 response' do + let(:request) { get api('/pages/domains', user) } + end + end + end + end + describe 'GET /projects/:project_id/pages/domains' do shared_examples_for 'get pages domains' do it 'returns paginated pages domains' do get api(route, user) expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/pages_domains') expect(response).to include_pagination_headers expect(json_response).to be_an Array expect(json_response.size).to eq(3) @@ -99,6 +137,7 @@ describe API::PagesDomains do get api(route_domain, user) expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/pages_domain/detail') expect(json_response['domain']).to eq(pages_domain.domain) expect(json_response['url']).to eq(pages_domain.url) expect(json_response['certificate']).to be_nil @@ -108,6 +147,7 @@ describe API::PagesDomains do get api(route_secure_domain, user) expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/pages_domain/detail') expect(json_response['domain']).to eq(pages_domain_secure.domain) expect(json_response['url']).to eq(pages_domain_secure.url) expect(json_response['certificate']['subject']).to eq(pages_domain_secure.subject) @@ -118,6 +158,7 @@ describe API::PagesDomains do get api(route_expired_domain, user) expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/pages_domain/detail') expect(json_response['certificate']['expired']).to be true end end @@ -187,6 +228,7 @@ describe API::PagesDomains do pages_domain = PagesDomain.find_by(domain: json_response['domain']) expect(response).to have_gitlab_http_status(201) + expect(response).to match_response_schema('public_api/v4/pages_domain/detail') expect(pages_domain.domain).to eq(params[:domain]) expect(pages_domain.certificate).to be_nil expect(pages_domain.key).to be_nil @@ -197,6 +239,7 @@ describe API::PagesDomains do pages_domain = PagesDomain.find_by(domain: json_response['domain']) expect(response).to have_gitlab_http_status(201) + expect(response).to match_response_schema('public_api/v4/pages_domain/detail') expect(pages_domain.domain).to eq(params_secure[:domain]) expect(pages_domain.certificate).to eq(params_secure[:certificate]) expect(pages_domain.key).to eq(params_secure[:key]) @@ -270,6 +313,7 @@ describe API::PagesDomains do pages_domain_secure.reload expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/pages_domain/detail') expect(pages_domain_secure.certificate).to be_nil expect(pages_domain_secure.key).to be_nil end @@ -279,6 +323,7 @@ describe API::PagesDomains do pages_domain.reload expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/pages_domain/detail') expect(pages_domain.certificate).to eq(params_secure[:certificate]) expect(pages_domain.key).to eq(params_secure[:key]) end @@ -288,6 +333,7 @@ describe API::PagesDomains do pages_domain_expired.reload expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/pages_domain/detail') expect(pages_domain_expired.certificate).to eq(params_secure[:certificate]) expect(pages_domain_expired.key).to eq(params_secure[:key]) end @@ -297,6 +343,7 @@ describe API::PagesDomains do pages_domain_secure.reload expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/pages_domain/detail') expect(pages_domain_secure.certificate).to eq(params_secure_nokey[:certificate]) end diff --git a/vendor/assets/javascripts/latinise.js b/vendor/assets/javascripts/latinise.js deleted file mode 100644 index da37966b28a..00000000000 --- a/vendor/assets/javascripts/latinise.js +++ /dev/null @@ -1,11 +0,0 @@ -// Converting text to basic latin (aka removing accents) -// -// Based on: http://semplicewebsites.com/removing-accents-javascript -// -var Latinise = { - map: {"Á":"A","Ă":"A","Ắ":"A","Ặ":"A","Ằ":"A","Ẳ":"A","Ẵ":"A","Ǎ":"A","Â":"A","Ấ":"A","Ậ":"A","Ầ":"A","Ẩ":"A","Ẫ":"A","Ä":"A","Ǟ":"A","Ȧ":"A","Ǡ":"A","Ạ":"A","Ȁ":"A","À":"A","Ả":"A","Ȃ":"A","Ā":"A","Ą":"A","Å":"A","Ǻ":"A","Ḁ":"A","Ⱥ":"A","Ã":"A","Ꜳ":"AA","Æ":"AE","Ǽ":"AE","Ǣ":"AE","Ꜵ":"AO","Ꜷ":"AU","Ꜹ":"AV","Ꜻ":"AV","Ꜽ":"AY","Ḃ":"B","Ḅ":"B","Ɓ":"B","Ḇ":"B","Ƀ":"B","Ƃ":"B","Ć":"C","Č":"C","Ç":"C","Ḉ":"C","Ĉ":"C","Ċ":"C","Ƈ":"C","Ȼ":"C","Ď":"D","Ḑ":"D","Ḓ":"D","Ḋ":"D","Ḍ":"D","Ɗ":"D","Ḏ":"D","Dz":"D","Dž":"D","Đ":"D","Ƌ":"D","DZ":"DZ","DŽ":"DZ","É":"E","Ĕ":"E","Ě":"E","Ȩ":"E","Ḝ":"E","Ê":"E","Ế":"E","Ệ":"E","Ề":"E","Ể":"E","Ễ":"E","Ḙ":"E","Ë":"E","Ė":"E","Ẹ":"E","Ȅ":"E","È":"E","Ẻ":"E","Ȇ":"E","Ē":"E","Ḗ":"E","Ḕ":"E","Ę":"E","Ɇ":"E","Ẽ":"E","Ḛ":"E","Ꝫ":"ET","Ḟ":"F","Ƒ":"F","Ǵ":"G","Ğ":"G","Ǧ":"G","Ģ":"G","Ĝ":"G","Ġ":"G","Ɠ":"G","Ḡ":"G","Ǥ":"G","Ḫ":"H","Ȟ":"H","Ḩ":"H","Ĥ":"H","Ⱨ":"H","Ḧ":"H","Ḣ":"H","Ḥ":"H","Ħ":"H","Í":"I","Ĭ":"I","Ǐ":"I","Î":"I","Ï":"I","Ḯ":"I","İ":"I","Ị":"I","Ȉ":"I","Ì":"I","Ỉ":"I","Ȋ":"I","Ī":"I","Į":"I","Ɨ":"I","Ĩ":"I","Ḭ":"I","Ꝺ":"D","Ꝼ":"F","Ᵹ":"G","Ꞃ":"R","Ꞅ":"S","Ꞇ":"T","Ꝭ":"IS","Ĵ":"J","Ɉ":"J","Ḱ":"K","Ǩ":"K","Ķ":"K","Ⱪ":"K","Ꝃ":"K","Ḳ":"K","Ƙ":"K","Ḵ":"K","Ꝁ":"K","Ꝅ":"K","Ĺ":"L","Ƚ":"L","Ľ":"L","Ļ":"L","Ḽ":"L","Ḷ":"L","Ḹ":"L","Ⱡ":"L","Ꝉ":"L","Ḻ":"L","Ŀ":"L","Ɫ":"L","Lj":"L","Ł":"L","LJ":"LJ","Ḿ":"M","Ṁ":"M","Ṃ":"M","Ɱ":"M","Ń":"N","Ň":"N","Ņ":"N","Ṋ":"N","Ṅ":"N","Ṇ":"N","Ǹ":"N","Ɲ":"N","Ṉ":"N","Ƞ":"N","Nj":"N","Ñ":"N","NJ":"NJ","Ó":"O","Ŏ":"O","Ǒ":"O","Ô":"O","Ố":"O","Ộ":"O","Ồ":"O","Ổ":"O","Ỗ":"O","Ö":"O","Ȫ":"O","Ȯ":"O","Ȱ":"O","Ọ":"O","Ő":"O","Ȍ":"O","Ò":"O","Ỏ":"O","Ơ":"O","Ớ":"O","Ợ":"O","Ờ":"O","Ở":"O","Ỡ":"O","Ȏ":"O","Ꝋ":"O","Ꝍ":"O","Ō":"O","Ṓ":"O","Ṑ":"O","Ɵ":"O","Ǫ":"O","Ǭ":"O","Ø":"O","Ǿ":"O","Õ":"O","Ṍ":"O","Ṏ":"O","Ȭ":"O","Ƣ":"OI","Ꝏ":"OO","Ɛ":"E","Ɔ":"O","Ȣ":"OU","Ṕ":"P","Ṗ":"P","Ꝓ":"P","Ƥ":"P","Ꝕ":"P","Ᵽ":"P","Ꝑ":"P","Ꝙ":"Q","Ꝗ":"Q","Ŕ":"R","Ř":"R","Ŗ":"R","Ṙ":"R","Ṛ":"R","Ṝ":"R","Ȑ":"R","Ȓ":"R","Ṟ":"R","Ɍ":"R","Ɽ":"R","Ꜿ":"C","Ǝ":"E","Ś":"S","Ṥ":"S","Š":"S","Ṧ":"S","Ş":"S","Ŝ":"S","Ș":"S","Ṡ":"S","Ṣ":"S","Ṩ":"S","ẞ":"SS","Ť":"T","Ţ":"T","Ṱ":"T","Ț":"T","Ⱦ":"T","Ṫ":"T","Ṭ":"T","Ƭ":"T","Ṯ":"T","Ʈ":"T","Ŧ":"T","Ɐ":"A","Ꞁ":"L","Ɯ":"M","Ʌ":"V","Ꜩ":"TZ","Ú":"U","Ŭ":"U","Ǔ":"U","Û":"U","Ṷ":"U","Ü":"U","Ǘ":"U","Ǚ":"U","Ǜ":"U","Ǖ":"U","Ṳ":"U","Ụ":"U","Ű":"U","Ȕ":"U","Ù":"U","Ủ":"U","Ư":"U","Ứ":"U","Ự":"U","Ừ":"U","Ử":"U","Ữ":"U","Ȗ":"U","Ū":"U","Ṻ":"U","Ų":"U","Ů":"U","Ũ":"U","Ṹ":"U","Ṵ":"U","Ꝟ":"V","Ṿ":"V","Ʋ":"V","Ṽ":"V","Ꝡ":"VY","Ẃ":"W","Ŵ":"W","Ẅ":"W","Ẇ":"W","Ẉ":"W","Ẁ":"W","Ⱳ":"W","Ẍ":"X","Ẋ":"X","Ý":"Y","Ŷ":"Y","Ÿ":"Y","Ẏ":"Y","Ỵ":"Y","Ỳ":"Y","Ƴ":"Y","Ỷ":"Y","Ỿ":"Y","Ȳ":"Y","Ɏ":"Y","Ỹ":"Y","Ź":"Z","Ž":"Z","Ẑ":"Z","Ⱬ":"Z","Ż":"Z","Ẓ":"Z","Ȥ":"Z","Ẕ":"Z","Ƶ":"Z","IJ":"IJ","Œ":"OE","ᴀ":"A","ᴁ":"AE","ʙ":"B","ᴃ":"B","ᴄ":"C","ᴅ":"D","ᴇ":"E","ꜰ":"F","ɢ":"G","ʛ":"G","ʜ":"H","ɪ":"I","ʁ":"R","ᴊ":"J","ᴋ":"K","ʟ":"L","ᴌ":"L","ᴍ":"M","ɴ":"N","ᴏ":"O","ɶ":"OE","ᴐ":"O","ᴕ":"OU","ᴘ":"P","ʀ":"R","ᴎ":"N","ᴙ":"R","ꜱ":"S","ᴛ":"T","ⱻ":"E","ᴚ":"R","ᴜ":"U","ᴠ":"V","ᴡ":"W","ʏ":"Y","ᴢ":"Z","á":"a","ă":"a","ắ":"a","ặ":"a","ằ":"a","ẳ":"a","ẵ":"a","ǎ":"a","â":"a","ấ":"a","ậ":"a","ầ":"a","ẩ":"a","ẫ":"a","ä":"a","ǟ":"a","ȧ":"a","ǡ":"a","ạ":"a","ȁ":"a","à":"a","ả":"a","ȃ":"a","ā":"a","ą":"a","ᶏ":"a","ẚ":"a","å":"a","ǻ":"a","ḁ":"a","ⱥ":"a","ã":"a","ꜳ":"aa","æ":"ae","ǽ":"ae","ǣ":"ae","ꜵ":"ao","ꜷ":"au","ꜹ":"av","ꜻ":"av","ꜽ":"ay","ḃ":"b","ḅ":"b","ɓ":"b","ḇ":"b","ᵬ":"b","ᶀ":"b","ƀ":"b","ƃ":"b","ɵ":"o","ć":"c","č":"c","ç":"c","ḉ":"c","ĉ":"c","ɕ":"c","ċ":"c","ƈ":"c","ȼ":"c","ď":"d","ḑ":"d","ḓ":"d","ȡ":"d","ḋ":"d","ḍ":"d","ɗ":"d","ᶑ":"d","ḏ":"d","ᵭ":"d","ᶁ":"d","đ":"d","ɖ":"d","ƌ":"d","ı":"i","ȷ":"j","ɟ":"j","ʄ":"j","dz":"dz","dž":"dz","é":"e","ĕ":"e","ě":"e","ȩ":"e","ḝ":"e","ê":"e","ế":"e","ệ":"e","ề":"e","ể":"e","ễ":"e","ḙ":"e","ë":"e","ė":"e","ẹ":"e","ȅ":"e","è":"e","ẻ":"e","ȇ":"e","ē":"e","ḗ":"e","ḕ":"e","ⱸ":"e","ę":"e","ᶒ":"e","ɇ":"e","ẽ":"e","ḛ":"e","ꝫ":"et","ḟ":"f","ƒ":"f","ᵮ":"f","ᶂ":"f","ǵ":"g","ğ":"g","ǧ":"g","ģ":"g","ĝ":"g","ġ":"g","ɠ":"g","ḡ":"g","ᶃ":"g","ǥ":"g","ḫ":"h","ȟ":"h","ḩ":"h","ĥ":"h","ⱨ":"h","ḧ":"h","ḣ":"h","ḥ":"h","ɦ":"h","ẖ":"h","ħ":"h","ƕ":"hv","í":"i","ĭ":"i","ǐ":"i","î":"i","ï":"i","ḯ":"i","ị":"i","ȉ":"i","ì":"i","ỉ":"i","ȋ":"i","ī":"i","į":"i","ᶖ":"i","ɨ":"i","ĩ":"i","ḭ":"i","ꝺ":"d","ꝼ":"f","ᵹ":"g","ꞃ":"r","ꞅ":"s","ꞇ":"t","ꝭ":"is","ǰ":"j","ĵ":"j","ʝ":"j","ɉ":"j","ḱ":"k","ǩ":"k","ķ":"k","ⱪ":"k","ꝃ":"k","ḳ":"k","ƙ":"k","ḵ":"k","ᶄ":"k","ꝁ":"k","ꝅ":"k","ĺ":"l","ƚ":"l","ɬ":"l","ľ":"l","ļ":"l","ḽ":"l","ȴ":"l","ḷ":"l","ḹ":"l","ⱡ":"l","ꝉ":"l","ḻ":"l","ŀ":"l","ɫ":"l","ᶅ":"l","ɭ":"l","ł":"l","lj":"lj","ſ":"s","ẜ":"s","ẛ":"s","ẝ":"s","ḿ":"m","ṁ":"m","ṃ":"m","ɱ":"m","ᵯ":"m","ᶆ":"m","ń":"n","ň":"n","ņ":"n","ṋ":"n","ȵ":"n","ṅ":"n","ṇ":"n","ǹ":"n","ɲ":"n","ṉ":"n","ƞ":"n","ᵰ":"n","ᶇ":"n","ɳ":"n","ñ":"n","nj":"nj","ó":"o","ŏ":"o","ǒ":"o","ô":"o","ố":"o","ộ":"o","ồ":"o","ổ":"o","ỗ":"o","ö":"o","ȫ":"o","ȯ":"o","ȱ":"o","ọ":"o","ő":"o","ȍ":"o","ò":"o","ỏ":"o","ơ":"o","ớ":"o","ợ":"o","ờ":"o","ở":"o","ỡ":"o","ȏ":"o","ꝋ":"o","ꝍ":"o","ⱺ":"o","ō":"o","ṓ":"o","ṑ":"o","ǫ":"o","ǭ":"o","ø":"o","ǿ":"o","õ":"o","ṍ":"o","ṏ":"o","ȭ":"o","ƣ":"oi","ꝏ":"oo","ɛ":"e","ᶓ":"e","ɔ":"o","ᶗ":"o","ȣ":"ou","ṕ":"p","ṗ":"p","ꝓ":"p","ƥ":"p","ᵱ":"p","ᶈ":"p","ꝕ":"p","ᵽ":"p","ꝑ":"p","ꝙ":"q","ʠ":"q","ɋ":"q","ꝗ":"q","ŕ":"r","ř":"r","ŗ":"r","ṙ":"r","ṛ":"r","ṝ":"r","ȑ":"r","ɾ":"r","ᵳ":"r","ȓ":"r","ṟ":"r","ɼ":"r","ᵲ":"r","ᶉ":"r","ɍ":"r","ɽ":"r","ↄ":"c","ꜿ":"c","ɘ":"e","ɿ":"r","ś":"s","ṥ":"s","š":"s","ṧ":"s","ş":"s","ŝ":"s","ș":"s","ṡ":"s","ṣ":"s","ṩ":"s","ʂ":"s","ᵴ":"s","ᶊ":"s","ȿ":"s","ɡ":"g","ß":"ss","ᴑ":"o","ᴓ":"o","ᴝ":"u","ť":"t","ţ":"t","ṱ":"t","ț":"t","ȶ":"t","ẗ":"t","ⱦ":"t","ṫ":"t","ṭ":"t","ƭ":"t","ṯ":"t","ᵵ":"t","ƫ":"t","ʈ":"t","ŧ":"t","ᵺ":"th","ɐ":"a","ᴂ":"ae","ǝ":"e","ᵷ":"g","ɥ":"h","ʮ":"h","ʯ":"h","ᴉ":"i","ʞ":"k","ꞁ":"l","ɯ":"m","ɰ":"m","ᴔ":"oe","ɹ":"r","ɻ":"r","ɺ":"r","ⱹ":"r","ʇ":"t","ʌ":"v","ʍ":"w","ʎ":"y","ꜩ":"tz","ú":"u","ŭ":"u","ǔ":"u","û":"u","ṷ":"u","ü":"u","ǘ":"u","ǚ":"u","ǜ":"u","ǖ":"u","ṳ":"u","ụ":"u","ű":"u","ȕ":"u","ù":"u","ủ":"u","ư":"u","ứ":"u","ự":"u","ừ":"u","ử":"u","ữ":"u","ȗ":"u","ū":"u","ṻ":"u","ų":"u","ᶙ":"u","ů":"u","ũ":"u","ṹ":"u","ṵ":"u","ᵫ":"ue","ꝸ":"um","ⱴ":"v","ꝟ":"v","ṿ":"v","ʋ":"v","ᶌ":"v","ⱱ":"v","ṽ":"v","ꝡ":"vy","ẃ":"w","ŵ":"w","ẅ":"w","ẇ":"w","ẉ":"w","ẁ":"w","ⱳ":"w","ẘ":"w","ẍ":"x","ẋ":"x","ᶍ":"x","ý":"y","ŷ":"y","ÿ":"y","ẏ":"y","ỵ":"y","ỳ":"y","ƴ":"y","ỷ":"y","ỿ":"y","ȳ":"y","ẙ":"y","ɏ":"y","ỹ":"y","ź":"z","ž":"z","ẑ":"z","ʑ":"z","ⱬ":"z","ż":"z","ẓ":"z","ȥ":"z","ẕ":"z","ᵶ":"z","ᶎ":"z","ʐ":"z","ƶ":"z","ɀ":"z","ff":"ff","ffi":"ffi","ffl":"ffl","fi":"fi","fl":"fl","ij":"ij","œ":"oe","st":"st","ₐ":"a","ₑ":"e","ᵢ":"i","ⱼ":"j","ₒ":"o","ᵣ":"r","ᵤ":"u","ᵥ":"v","ₓ":"x"} -}; - -String.prototype.latinise = function() { - return this.replace(/[^A-Za-z0-9]/g, function(x) { return Latinise.map[x] || x; }); -}; |