diff options
author | Matija Čupić <matteeyah@gmail.com> | 2017-12-21 18:30:34 +0100 |
---|---|---|
committer | Matija Čupić <matteeyah@gmail.com> | 2017-12-21 18:30:34 +0100 |
commit | 305bce8d246d2c6e88b5f22439c0ce0833eba1a3 (patch) | |
tree | e043cb4041c121957610f81d6a65790e91f84fb9 /app | |
parent | 614c0e0bf9c404ba43f835166183a2f1883071d1 (diff) | |
parent | b8d79cc479200ff714f89dc43a3bbec18af3c5b5 (diff) | |
download | gitlab-ce-305bce8d246d2c6e88b5f22439c0ce0833eba1a3.tar.gz |
Merge branch 'master' into 39957-redirect-to-gpc-page-if-users-try-to-create-a-cluster-but-the-account-is-not-enabled
Diffstat (limited to 'app')
311 files changed, 4604 insertions, 2971 deletions
diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js index 5d060165f4b..6a0662ba903 100644 --- a/app/assets/javascripts/activities.js +++ b/app/assets/javascripts/activities.js @@ -1,9 +1,10 @@ /* eslint-disable no-param-reassign, class-methods-use-this */ -/* global Pager */ import Cookies from 'js-cookie'; +import Pager from './pager'; +import { localTimeAgo } from './lib/utils/datetime_utility'; -class Activities { +export default class Activities { constructor() { Pager.init(20, true, false, data => data, this.updateTooltips); @@ -15,7 +16,7 @@ class Activities { } updateTooltips() { - gl.utils.localTimeAgo($('.js-timeago', '.content_list')); + localTimeAgo($('.js-timeago', '.content_list')); } reloadActivities() { @@ -33,6 +34,3 @@ class Activities { $sender.closest('li').toggleClass('active'); } } - -window.gl = window.gl || {}; -window.gl.Activities = Activities; diff --git a/app/assets/javascripts/admin.js b/app/assets/javascripts/admin.js index b0b72c40f25..c1f7fa2aced 100644 --- a/app/assets/javascripts/admin.js +++ b/app/assets/javascripts/admin.js @@ -1,63 +1,59 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, one-var, no-var, one-var-declaration-per-line, no-unused-vars, no-else-return, prefer-arrow-callback, camelcase, quotes, comma-dangle, max-len */ import { refreshCurrentPage } from './lib/utils/url_utility'; -window.Admin = (function() { - function Admin() { - var modal, showBlacklistType; - $('input#user_force_random_password').on('change', function(elem) { - var elems; - elems = $('#user_password, #user_password_confirmation'); - if ($(this).attr('checked')) { - return elems.val('').attr('disabled', true); - } else { - return elems.removeAttr('disabled'); - } - }); - $('body').on('click', '.js-toggle-colors-link', function(e) { - e.preventDefault(); - return $('.js-toggle-colors-container').toggle(); - }); - $('.log-tabs a').click(function(e) { - e.preventDefault(); - return $(this).tab('show'); - }); - $('.log-bottom').click(function(e) { - var visible_log; - e.preventDefault(); - visible_log = $(".file-content:visible"); - return visible_log.animate({ - scrollTop: visible_log.find('ol').height() - }, "fast"); - }); - modal = $('.change-owner-holder'); - $('.change-owner-link').bind("click", function(e) { - e.preventDefault(); - $(this).hide(); - return modal.show(); - }); - $('.change-owner-cancel-link').bind("click", function(e) { - e.preventDefault(); - modal.hide(); - return $('.change-owner-link').show(); - }); - $('li.project_member').bind('ajax:success', function() { - return refreshCurrentPage(); - }); - $('li.group_member').bind('ajax:success', function() { - return refreshCurrentPage(); - }); - showBlacklistType = function() { - if ($("input[name='blacklist_type']:checked").val() === 'file') { - $('.blacklist-file').show(); - return $('.blacklist-raw').hide(); - } else { - $('.blacklist-file').hide(); - return $('.blacklist-raw').show(); - } - }; - $("input[name='blacklist_type']").click(showBlacklistType); - showBlacklistType(); +function showBlacklistType() { + if ($('input[name="blacklist_type"]:checked').val() === 'file') { + $('.blacklist-file').show(); + $('.blacklist-raw').hide(); + } else { + $('.blacklist-file').hide(); + $('.blacklist-raw').show(); } +} - return Admin; -})(); +export default function adminInit() { + const modal = $('.change-owner-holder'); + + $('input#user_force_random_password').on('change', function randomPasswordClick() { + const $elems = $('#user_password, #user_password_confirmation'); + if ($(this).attr('checked')) { + $elems.val('').attr('disabled', true); + } else { + $elems.removeAttr('disabled'); + } + }); + + $('body').on('click', '.js-toggle-colors-link', (e) => { + e.preventDefault(); + $('.js-toggle-colors-container').toggle(); + }); + + $('.log-tabs a').on('click', function logTabsClick(e) { + e.preventDefault(); + $(this).tab('show'); + }); + + $('.log-bottom').on('click', (e) => { + e.preventDefault(); + const $visibleLog = $('.file-content:visible'); + $visibleLog.animate({ + scrollTop: $visibleLog.find('ol').height(), + }, 'fast'); + }); + + $('.change-owner-link').on('click', function changeOwnerLinkClick(e) { + e.preventDefault(); + $(this).hide(); + modal.show(); + }); + + $('.change-owner-cancel-link').on('click', (e) => { + e.preventDefault(); + modal.hide(); + $('.change-owner-link').show(); + }); + + $('li.project_member, li.group_member').on('ajax:success', refreshCurrentPage); + + $("input[name='blacklist_type']").on('click', showBlacklistType); + showBlacklistType(); +} diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index d963101028a..21d8c790e90 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import axios from './lib/utils/axios_utils'; const Api = { groupsPath: '/api/:version/groups.json', @@ -6,6 +7,7 @@ const Api = { namespacesPath: '/api/:version/namespaces.json', groupProjectsPath: '/api/:version/groups/:id/projects.json', projectsPath: '/api/:version/projects.json', + projectPath: '/api/:version/projects/:id', projectLabelsPath: '/:namespace_path/:project_path/labels', groupLabelsPath: '/groups/:namespace_path/labels', licensePath: '/api/:version/templates/licenses/:key', @@ -76,6 +78,14 @@ const Api = { .done(projects => callback(projects)); }, + // Return single project + project(projectPath) { + const url = Api.buildUrl(Api.projectPath) + .replace(':id', encodeURIComponent(projectPath)); + + return axios.get(url); + }, + newLabel(namespacePath, projectPath, data, callback) { let url; @@ -115,7 +125,7 @@ const Api = { commitMultiple(id, data) { // see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions const url = Api.buildUrl(Api.commitPath) - .replace(':id', id); + .replace(':id', encodeURIComponent(id)); return this.wrapAjaxCall({ url, type: 'POST', @@ -127,7 +137,7 @@ const Api = { branchSingle(id, branch) { const url = Api.buildUrl(Api.branchSinglePath) - .replace(':id', id) + .replace(':id', encodeURIComponent(id)) .replace(':branch', branch); return this.wrapAjaxCall({ diff --git a/app/assets/javascripts/aside.js b/app/assets/javascripts/aside.js deleted file mode 100644 index 88756884d16..00000000000 --- a/app/assets/javascripts/aside.js +++ /dev/null @@ -1,24 +0,0 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, quotes, prefer-arrow-callback, no-var, one-var, one-var-declaration-per-line, no-else-return, max-len */ - -window.Aside = (function() { - function Aside() { - $(document).off("click", "a.show-aside"); - $(document).on("click", 'a.show-aside', function(e) { - var btn, icon; - e.preventDefault(); - btn = $(e.currentTarget); - icon = btn.find('i'); - if (icon.hasClass('fa-angle-left')) { - btn.parent().find('section').hide(); - btn.parent().find('aside').fadeIn(); - return icon.removeClass('fa-angle-left').addClass('fa-angle-right'); - } else { - btn.parent().find('aside').hide(); - btn.parent().find('section').fadeIn(); - return icon.removeClass('fa-angle-right').addClass('fa-angle-left'); - } - }); - } - - return Aside; -})(); diff --git a/app/assets/javascripts/behaviors/secret_values.js b/app/assets/javascripts/behaviors/secret_values.js new file mode 100644 index 00000000000..1cf0b960eb0 --- /dev/null +++ b/app/assets/javascripts/behaviors/secret_values.js @@ -0,0 +1,42 @@ +import { n__ } from '../locale'; +import { convertPermissionToBoolean } from '../lib/utils/common_utils'; + +export default class SecretValues { + constructor(container) { + this.container = container; + } + + init() { + this.values = this.container.querySelectorAll('.js-secret-value'); + this.placeholders = this.container.querySelectorAll('.js-secret-value-placeholder'); + this.revealButton = this.container.querySelector('.js-secret-value-reveal-button'); + + this.revealText = n__('Reveal value', 'Reveal values', this.values.length); + this.hideText = n__('Hide value', 'Hide values', this.values.length); + + const isRevealed = convertPermissionToBoolean(this.revealButton.dataset.secretRevealStatus); + this.updateDom(isRevealed); + + this.revealButton.addEventListener('click', this.onRevealButtonClicked.bind(this)); + } + + onRevealButtonClicked() { + const previousIsRevealed = convertPermissionToBoolean( + this.revealButton.dataset.secretRevealStatus, + ); + this.updateDom(!previousIsRevealed); + } + + updateDom(isRevealed) { + this.values.forEach((value) => { + value.classList.toggle('hide', !isRevealed); + }); + + this.placeholders.forEach((placeholder) => { + placeholder.classList.toggle('hide', isRevealed); + }); + + this.revealButton.textContent = isRevealed ? this.hideText : this.revealText; + this.revealButton.dataset.secretRevealStatus = isRevealed; + } +} diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index faa76da964f..616de2347e1 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -1,9 +1,9 @@ /* eslint-disable comma-dangle, space-before-function-paren, no-new */ /* global MilestoneSelect */ -/* global Sidebar */ import Vue from 'vue'; import Flash from '../../flash'; +import Sidebar from '../../right_sidebar'; import eventHub from '../../sidebar/event_hub'; import assigneeTitle from '../../sidebar/components/assignees/assignee_title'; import assignees from '../../sidebar/components/assignees/assignees'; diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js index 5662802525e..b6a0ece7907 100644 --- a/app/assets/javascripts/commit/image_file.js +++ b/app/assets/javascripts/commit/image_file.js @@ -176,6 +176,7 @@ export default class ImageFile { left: dragTrackWidth }); + $frameAdded.css('opacity', 1); framePadding = parseInt($frameAdded.css('right').replace('px', ''), 10); _this.initDraggable($dragger, framePadding, function(e, left) { diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js index 9b952ea7b60..3a03cbf6b90 100644 --- a/app/assets/javascripts/commits.js +++ b/app/assets/javascripts/commits.js @@ -1,9 +1,10 @@ /* eslint-disable func-names, wrap-iife, consistent-return, no-return-assign, no-param-reassign, one-var-declaration-per-line, no-unused-vars, prefer-template, object-shorthand, prefer-arrow-callback */ -/* global Pager */ import { pluralize } from './lib/utils/text_utility'; +import { localTimeAgo } from './lib/utils/datetime_utility'; +import Pager from './pager'; export default (function () { const CommitsList = {}; @@ -91,7 +92,7 @@ export default (function () { $commitsHeadersLast.find('span.commits-count').text(`${commitsCount} ${pluralize('commit', commitsCount)}`); } - gl.utils.localTimeAgo($processedData.find('.js-timeago')); + localTimeAgo($processedData.find('.js-timeago')); return processedData; }; diff --git a/app/assets/javascripts/compare.js b/app/assets/javascripts/compare.js index 0ce467a3bd4..144caf1d278 100644 --- a/app/assets/javascripts/compare.js +++ b/app/assets/javascripts/compare.js @@ -1,4 +1,5 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, quotes, no-var, object-shorthand, consistent-return, no-unused-vars, comma-dangle, vars-on-top, prefer-template, max-len */ +import { localTimeAgo } from './lib/utils/datetime_utility'; export default class Compare { constructor(opts) { @@ -81,7 +82,7 @@ export default class Compare { loading.hide(); $target.html(html); var className = '.' + $target[0].className.replace(' ', '.'); - gl.utils.localTimeAgo($('.js-timeago', className)); + localTimeAgo($('.js-timeago', className)); } }); } diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue index b41d464475f..2a05c6f001e 100644 --- a/app/assets/javascripts/deploy_keys/components/key.vue +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -1,5 +1,6 @@ <script> import actionBtn from './action_btn.vue'; + import { getTimeago } from '../../lib/utils/datetime_utility'; export default { props: { @@ -21,7 +22,7 @@ }, computed: { timeagoDate() { - return gl.utils.getTimeago().format(this.deployKey.created_at); + return getTimeago().format(this.deployKey.created_at); }, editDeployKeyPath() { return `${this.endpoint}/${this.deployKey.id}/edit`; diff --git a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js index 06ce84d7599..300b02da663 100644 --- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js +++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js @@ -1,8 +1,8 @@ /* global CommentsStore */ -/* global notes */ import Vue from 'vue'; import collapseIcon from '../icons/collapse_icon.svg'; +import Notes from '../../notes'; import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; const DiffNoteAvatars = Vue.extend({ @@ -129,7 +129,7 @@ const DiffNoteAvatars = Vue.extend({ }, methods: { clickedAvatar(e) { - notes.onAddDiffNote(e); + Notes.instance.onAddDiffNote(e); // Toggle the active state of the toggle all button this.toggleDiscussionsToggleState(); diff --git a/app/assets/javascripts/diff_notes/models/discussion.js b/app/assets/javascripts/diff_notes/models/discussion.js index dc43e4b2cc7..1b8a9af9390 100644 --- a/app/assets/javascripts/diff_notes/models/discussion.js +++ b/app/assets/javascripts/diff_notes/models/discussion.js @@ -2,6 +2,7 @@ /* global NoteModel */ import Vue from 'vue'; +import { localTimeAgo } from '../../lib/utils/datetime_utility'; class DiscussionModel { constructor (discussionId) { @@ -71,7 +72,7 @@ class DiscussionModel { $(`${discussionSelector} .discussion-header`).append(data.discussion_headline_html); } - gl.utils.localTimeAgo($('.js-timeago', `${discussionSelector}`)); + localTimeAgo($('.js-timeago', `${discussionSelector}`)); } else { $discussionHeadline.remove(); } diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 12402fd645f..118437b82a3 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -7,21 +7,21 @@ import IssuableForm from './issuable_form'; import LabelsSelect from './labels_select'; /* global MilestoneSelect */ import NewBranchForm from './new_branch_form'; -/* global NotificationsForm */ -/* global NotificationsDropdown */ +import NotificationsForm from './notifications_form'; +import notificationsDropdown from './notifications_dropdown'; import groupAvatar from './group_avatar'; import GroupLabelSubscription from './group_label_subscription'; -/* global LineHighlighter */ +import LineHighlighter from './line_highlighter'; import BuildArtifacts from './build_artifacts'; import CILintEditor from './ci_lint_editor'; import groupsSelect from './groups_select'; import Search from './search'; -/* global Admin */ +import initAdmin from './admin'; import NamespaceSelect from './namespace_select'; import NewCommitForm from './new_commit_form'; import Project from './project'; import projectAvatar from './project_avatar'; -/* global MergeRequest */ +import MergeRequest from './merge_request'; import Compare from './compare'; import initCompareAutocomplete from './compare_autocomplete'; import ProjectFindFile from './project_find_file'; @@ -29,12 +29,13 @@ import ProjectNew from './project_new'; import projectImport from './project_import'; import Labels from './labels'; import LabelManager from './label_manager'; -/* global Sidebar */ +import Sidebar from './right_sidebar'; import IssuableTemplateSelectors from './templates/issuable_template_selectors'; import Flash from './flash'; import CommitsList from './commits'; import Issue from './issue'; import BindInOut from './behaviors/bind_in_out'; +import SecretValues from './behaviors/secret_values'; import DeleteModal from './branches/branches_delete_modal'; import Group from './group'; import GroupsList from './groups_list'; @@ -72,7 +73,6 @@ import initLegacyFilters from './init_legacy_filters'; import initIssuableSidebar from './init_issuable_sidebar'; import initProjectVisibilitySelector from './project_visibility'; import GpgBadges from './gpg_badges'; -import UserFeatureHelper from './helpers/user_feature_helper'; import initChangesDropdown from './init_changes_dropdown'; import NewGroupChild from './groups/new_group_child'; import AbuseReports from './abuse_reports'; @@ -90,8 +90,8 @@ import memberExpirationDate from './member_expiration_date'; import DueDateSelectors from './due_date_select'; import Diff from './diff'; import ProjectLabelSubscription from './project_label_subscription'; -import ProjectVariables from './project_variables'; import SearchAutocomplete from './search_autocomplete'; +import Activities from './activities'; (function() { var Dispatcher; @@ -110,6 +110,8 @@ import SearchAutocomplete from './search_autocomplete'; return false; } + const fail = () => Flash('Error loading dynamic module'); + path = page.split(':'); shortcut_handler = null; @@ -334,7 +336,7 @@ import SearchAutocomplete from './search_autocomplete'; shortcut_handler = new ShortcutsIssuable(true); break; case 'dashboard:activity': - new gl.Activities(); + new Activities(); break; case 'projects:commit:show': new Diff(); @@ -355,7 +357,7 @@ import SearchAutocomplete from './search_autocomplete'; $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath); break; case 'projects:activity': - new gl.Activities(); + new Activities(); shortcut_handler = new ShortcutsNavigation(); break; case 'projects:commits:show': @@ -373,7 +375,7 @@ import SearchAutocomplete from './search_autocomplete'; if ($('#tree-slider').length) new TreeView(); if ($('.blob-viewer').length) new BlobViewer(); - if ($('.project-show-activity').length) new gl.Activities(); + if ($('.project-show-activity').length) new Activities(); $('#tree-slider').waitForImages(function() { ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath); }); @@ -407,13 +409,13 @@ import SearchAutocomplete from './search_autocomplete'; }); break; case 'groups:activity': - new gl.Activities(); + new Activities(); break; case 'groups:show': const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup'); shortcut_handler = new ShortcutsNavigation(); new NotificationsForm(); - new NotificationsDropdown(); + notificationsDropdown(); new ProjectsList(); if (newGroupChildWrapper) { @@ -446,9 +448,6 @@ import SearchAutocomplete from './search_autocomplete'; break; case 'projects:tree:show': shortcut_handler = new ShortcutsNavigation(); - - if (UserFeatureHelper.isNewRepoEnabled()) break; - new TreeView(); new BlobViewer(); new NewCommitForm($('.js-create-dir-form')); @@ -467,7 +466,6 @@ import SearchAutocomplete from './search_autocomplete'; shortcut_handler = true; break; case 'projects:blob:show': - if (UserFeatureHelper.isNewRepoEnabled()) break; new BlobViewer(); initBlob(); break; @@ -526,15 +524,25 @@ import SearchAutocomplete from './search_autocomplete'; case 'projects:settings:ci_cd:show': // Initialize expandable settings panels initSettingsPanels(); + + const runnerToken = document.querySelector('.js-secret-runner-token'); + if (runnerToken) { + const runnerTokenSecretValue = new SecretValues(runnerToken); + runnerTokenSecretValue.init(); + } case 'groups:settings:ci_cd:show': - new ProjectVariables(); + const secretVariableTable = document.querySelector('.js-secret-variable-table'); + if (secretVariableTable) { + const secretVariableTableValues = new SecretValues(secretVariableTable); + secretVariableTableValues.init(); + } break; case 'ci:lints:create': case 'ci:lints:show': new CILintEditor(); break; case 'users:show': - new UserCallout(); + import('./pages/users/show').then(m => m.default()).catch(fail); break; case 'admin:conversational_development_index:show': new UserCallout(); @@ -584,7 +592,7 @@ import SearchAutocomplete from './search_autocomplete'; // needed in rspec gl.u2fAuthenticate = u2fAuthenticate; case 'admin': - new Admin(); + initAdmin(); switch (path[1]) { case 'broadcast_messages': initBroadcastMessagesForm(); @@ -616,7 +624,7 @@ import SearchAutocomplete from './search_autocomplete'; break; case 'profiles': new NotificationsForm(); - new NotificationsDropdown(); + notificationsDropdown(); break; case 'projects': new Project(); @@ -639,7 +647,7 @@ import SearchAutocomplete from './search_autocomplete'; case 'show': new Star(); new ProjectNew(); - new NotificationsDropdown(); + notificationsDropdown(); break; case 'wikis': new Wikis(); diff --git a/app/assets/javascripts/docs/docs_bundle.js b/app/assets/javascripts/docs/docs_bundle.js new file mode 100644 index 00000000000..a32bd6d0fc7 --- /dev/null +++ b/app/assets/javascripts/docs/docs_bundle.js @@ -0,0 +1,13 @@ +import Mousetrap from 'mousetrap'; + +function addMousetrapClick(el, key) { + el.addEventListener('click', () => Mousetrap.trigger(key)); +} + +function domContentLoaded() { + addMousetrapClick(document.querySelector('.js-trigger-shortcut'), '?'); + addMousetrapClick(document.querySelector('.js-trigger-search-bar'), 's'); +} + +document.addEventListener('DOMContentLoaded', domContentLoaded); + diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js index 6110d961609..abb04d77f8f 100644 --- a/app/assets/javascripts/fly_out_nav.js +++ b/app/assets/javascripts/fly_out_nav.js @@ -161,13 +161,16 @@ export default () => { const items = [...sidebar.querySelectorAll('.sidebar-top-level-items > li')]; - sidebar.querySelector('.sidebar-top-level-items').addEventListener('mouseleave', () => { - clearTimeout(timeoutId); - - timeoutId = setTimeout(() => { - if (currentOpenMenu) hideMenu(currentOpenMenu); - }, getHideSubItemsInterval()); - }); + const topItems = sidebar.querySelector('.sidebar-top-level-items'); + if (topItems) { + sidebar.querySelector('.sidebar-top-level-items').addEventListener('mouseleave', () => { + clearTimeout(timeoutId); + + timeoutId = setTimeout(() => { + if (currentOpenMenu) hideMenu(currentOpenMenu); + }, getHideSubItemsInterval()); + }); + } headerHeight = document.querySelector('.nav-sidebar').offsetTop; diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index cf4a70e321e..64f258aed64 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -300,7 +300,7 @@ GitLabDropdown = (function() { return function(data) { _this.fullData = data; _this.parseData(_this.fullData); - _this.focusTextInput(true); + _this.focusTextInput(); if (_this.options.filterable && _this.filter && _this.filter.input && _this.filter.input.val() && _this.filter.input.val().trim() !== '') { return _this.filter.input.trigger('input'); } @@ -790,24 +790,16 @@ GitLabDropdown = (function() { return [selectedObject, isMarking]; }; - GitLabDropdown.prototype.focusTextInput = function(triggerFocus = false) { + GitLabDropdown.prototype.focusTextInput = function() { if (this.options.filterable) { - this.dropdown.one('transitionend', () => { - const initialScrollTop = $(window).scrollTop(); + const initialScrollTop = $(window).scrollTop(); - if (this.dropdown.is('.open')) { - this.filterInput.focus(); - } - - if ($(window).scrollTop() < initialScrollTop) { - $(window).scrollTop(initialScrollTop); - } - }); + if (this.dropdown.is('.open')) { + this.filterInput.focus(); + } - if (triggerFocus) { - // This triggers after a ajax request - // in case of slow requests, the dropdown transition could already be finished - this.dropdown.trigger('transitionend'); + if ($(window).scrollTop() < initialScrollTop) { + $(window).scrollTop(initialScrollTop); } } }; diff --git a/app/assets/javascripts/graphs/stat_graph_contributors.js b/app/assets/javascripts/graphs/stat_graph_contributors.js index e7232ca3712..151a4ce012c 100644 --- a/app/assets/javascripts/graphs/stat_graph_contributors.js +++ b/app/assets/javascripts/graphs/stat_graph_contributors.js @@ -1,13 +1,14 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, camelcase, one-var-declaration-per-line, quotes, no-param-reassign, quote-props, comma-dangle, prefer-template, max-len, no-return-assign, no-shadow */ import _ from 'underscore'; -import d3 from 'd3'; import { ContributorsGraph, ContributorsAuthorGraph, ContributorsMasterGraph } from './stat_graph_contributors_graph'; import ContributorsStatGraphUtil from './stat_graph_contributors_util'; -import { n__ } from '../locale'; +import { n__, s__, createDateTimeFormat, sprintf } from '../locale'; export default (function() { - function ContributorsStatGraph() {} + function ContributorsStatGraph() { + this.dateFormat = createDateTimeFormat({ year: 'numeric', month: 'long', day: 'numeric' }); + } ContributorsStatGraph.prototype.init = function(log) { var author_commits, total_commits; @@ -83,9 +84,12 @@ export default (function() { return _.each(author_commits, (function(_this) { return function(d) { _this.redraw_author_commit_info(d); - $(_this.authors[d.author_name].list_item).appendTo("ol"); - _this.authors[d.author_name].set_data(d.dates); - return _this.authors[d.author_name].redraw(); + if (_this.authors[d.author_name] != null) { + $(_this.authors[d.author_name].list_item).appendTo("ol"); + _this.authors[d.author_name].set_data(d.dates); + return _this.authors[d.author_name].redraw(); + } + return ''; }; })(this)); }; @@ -95,18 +99,26 @@ export default (function() { }; ContributorsStatGraph.prototype.change_date_header = function() { - var print, print_date_format, x_domain; - x_domain = ContributorsGraph.prototype.x_domain; - print_date_format = d3.time.format("%B %e %Y"); - print = print_date_format(x_domain[0]) + " - " + print_date_format(x_domain[1]); - return $("#date_header").text(print); + const x_domain = ContributorsGraph.prototype.x_domain; + const formattedDateRange = sprintf( + s__('ContributorsPage|%{startDate} – %{endDate}'), + { + startDate: this.dateFormat.format(new Date(x_domain[0])), + endDate: this.dateFormat.format(new Date(x_domain[1])), + }, + ); + return $('#date_header').text(formattedDateRange); }; ContributorsStatGraph.prototype.redraw_author_commit_info = function(author) { - var author_commit_info, author_list_item; - author_list_item = $(this.authors[author.author_name].list_item); - author_commit_info = this.format_author_commit_info(author); - return author_list_item.find("span").html(author_commit_info); + var author_commit_info, author_list_item, $author; + $author = this.authors[author.author_name]; + if ($author != null) { + author_list_item = $(this.authors[author.author_name].list_item); + author_commit_info = this.format_author_commit_info(author); + return author_list_item.find("span").html(author_commit_info); + } + return ''; }; return ContributorsStatGraph; diff --git a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js index f64b4638485..9a4012232a0 100644 --- a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js +++ b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js @@ -1,6 +1,15 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, max-len, no-restricted-syntax, vars-on-top, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, wrap-iife, comma-dangle, no-return-assign, prefer-arrow-callback, quotes, prefer-template, newline-per-chained-call, no-else-return, no-shadow */ import _ from 'underscore'; -import d3 from 'd3'; +import { extent, max } from 'd3-array'; +import { select, event as d3Event } from 'd3-selection'; +import { scaleTime, scaleLinear } from 'd3-scale'; +import { axisLeft, axisBottom } from 'd3-axis'; +import { area } from 'd3-shape'; +import { brushX } from 'd3-brush'; +import { timeParse } from 'd3-time-format'; +import { dateTickFormat } from '../lib/utils/tick_formats'; + +const d3 = { extent, max, select, scaleTime, scaleLinear, axisLeft, axisBottom, area, brushX, timeParse }; const extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; const hasProp = {}.hasOwnProperty; @@ -70,8 +79,8 @@ export const ContributorsGraph = (function() { }; ContributorsGraph.prototype.create_scale = function(width, height) { - this.x = d3.time.scale().range([0, width]).clamp(true); - return this.y = d3.scale.linear().range([height, 0]).nice(); + this.x = d3.scaleTime().range([0, width]).clamp(true); + return this.y = d3.scaleLinear().range([height, 0]).nice(); }; ContributorsGraph.prototype.draw_x_axis = function() { @@ -93,9 +102,12 @@ export const ContributorsMasterGraph = (function(superClass) { extend(ContributorsMasterGraph, superClass); function ContributorsMasterGraph(data1) { + const $parentElement = $('#contributors-master'); + const parentPadding = parseFloat($parentElement.css('padding-left')) + parseFloat($parentElement.css('padding-right')); + this.data = data1; this.update_content = this.update_content.bind(this); - this.width = $('.content').width() - 70; + this.width = $('.content').width() - parentPadding - (this.MARGIN.left + this.MARGIN.right); this.height = 200; this.x = null; this.y = null; @@ -120,7 +132,7 @@ export const ContributorsMasterGraph = (function(superClass) { ContributorsMasterGraph.prototype.parse_dates = function(data) { var parseDate; - parseDate = d3.time.format("%Y-%m-%d").parse; + parseDate = d3.timeParse("%Y-%m-%d"); return data.forEach(function(d) { return d.date = parseDate(d.date); }); @@ -131,8 +143,10 @@ export const ContributorsMasterGraph = (function(superClass) { }; ContributorsMasterGraph.prototype.create_axes = function() { - this.x_axis = d3.svg.axis().scale(this.x).orient("bottom"); - return this.y_axis = d3.svg.axis().scale(this.y).orient("left").ticks(5); + this.x_axis = d3.axisBottom() + .scale(this.x) + .tickFormat(dateTickFormat); + return this.y_axis = d3.axisLeft().scale(this.y).ticks(5); }; ContributorsMasterGraph.prototype.create_svg = function() { @@ -140,16 +154,16 @@ export const ContributorsMasterGraph = (function(superClass) { }; ContributorsMasterGraph.prototype.create_area = function(x, y) { - return this.area = d3.svg.area().x(function(d) { + return this.area = d3.area().x(function(d) { return x(d.date); }).y0(this.height).y1(function(d) { d.commits = d.commits || d.additions || d.deletions; return y(d.commits); - }).interpolate("basis"); + }); }; ContributorsMasterGraph.prototype.create_brush = function() { - return this.brush = d3.svg.brush().x(this.x).on("brushend", this.update_content); + return this.brush = d3.brushX(this.x).extent([[this.x.range()[0], 0], [this.x.range()[1], this.height]]).on("end", this.update_content); }; ContributorsMasterGraph.prototype.draw_path = function(data) { @@ -161,7 +175,12 @@ export const ContributorsMasterGraph = (function(superClass) { }; ContributorsMasterGraph.prototype.update_content = function() { - ContributorsGraph.set_x_domain(this.brush.empty() ? this.x_max_domain : this.brush.extent()); + // d3Event.selection replaces the function brush.empty() calls + if (d3Event.selection != null) { + ContributorsGraph.set_x_domain(d3Event.selection.map(this.x.invert)); + } else { + ContributorsGraph.set_x_domain(this.x_max_domain); + } return $("#brush_change").trigger('change'); }; @@ -219,14 +238,17 @@ export const ContributorsAuthorGraph = (function(superClass) { }; ContributorsAuthorGraph.prototype.create_axes = function() { - this.x_axis = d3.svg.axis().scale(this.x).orient("bottom").ticks(8); - return this.y_axis = d3.svg.axis().scale(this.y).orient("left").ticks(5); + this.x_axis = d3.axisBottom() + .scale(this.x) + .ticks(8) + .tickFormat(dateTickFormat); + return this.y_axis = d3.axisLeft().scale(this.y).ticks(5); }; ContributorsAuthorGraph.prototype.create_area = function(x, y) { - return this.area = d3.svg.area().x(function(d) { + return this.area = d3.area().x(function(d) { var parseDate; - parseDate = d3.time.format("%Y-%m-%d").parse; + parseDate = d3.timeParse("%Y-%m-%d"); return x(parseDate(d)); }).y0(this.height).y1((function(_this) { return function(d) { @@ -236,11 +258,12 @@ export const ContributorsAuthorGraph = (function(superClass) { return y(0); } }; - })(this)).interpolate("basis"); + })(this)); }; ContributorsAuthorGraph.prototype.create_svg = function() { - this.list_item = d3.selectAll(".person")[0].pop(); + var persons = document.querySelectorAll('.person'); + this.list_item = persons[persons.length - 1]; return this.svg = d3.select(this.list_item).append("svg").attr("width", this.width + this.MARGIN.left + this.MARGIN.right).attr("height", this.height + this.MARGIN.top + this.MARGIN.bottom).attr("class", "spark").append("g").attr("transform", "translate(" + this.MARGIN.left + "," + this.MARGIN.top + ")"); }; diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue index 09cb79c1afd..58ba5aff7cf 100644 --- a/app/assets/javascripts/groups/components/item_actions.vue +++ b/app/assets/javascripts/groups/components/item_actions.vue @@ -1,7 +1,7 @@ <script> import { s__ } from '../../locale'; import tooltip from '../../vue_shared/directives/tooltip'; -import PopupDialog from '../../vue_shared/components/popup_dialog.vue'; +import modal from '../../vue_shared/components/modal.vue'; import eventHub from '../event_hub'; import { COMMON_STR } from '../constants'; import Icon from '../../vue_shared/components/icon.vue'; @@ -9,7 +9,7 @@ import Icon from '../../vue_shared/components/icon.vue'; export default { components: { Icon, - PopupDialog, + modal, }, directives: { tooltip, @@ -27,7 +27,7 @@ export default { }, data() { return { - dialogStatus: false, + modalStatus: false, }; }, computed: { @@ -43,10 +43,10 @@ export default { }, methods: { onLeaveGroup() { - this.dialogStatus = true; + this.modalStatus = true; }, leaveGroup(leaveConfirmed) { - this.dialogStatus = false; + this.modalStatus = false; if (leaveConfirmed) { eventHub.$emit('leaveGroup', this.group, this.parentGroup); } @@ -82,8 +82,8 @@ export default { class="fa fa-sign-out" aria-hidden="true"/> </a> - <popup-dialog - v-show="dialogStatus" + <modal + v-show="modalStatus" :primary-button-label="__('Leave')" kind="warning" :title="__('Are you sure?')" diff --git a/app/assets/javascripts/helpers/user_feature_helper.js b/app/assets/javascripts/helpers/user_feature_helper.js deleted file mode 100644 index 638118a5204..00000000000 --- a/app/assets/javascripts/helpers/user_feature_helper.js +++ /dev/null @@ -1,7 +0,0 @@ -import Cookies from 'js-cookie'; - -export default { - isNewRepoEnabled() { - return Cookies.get('new_repo') === 'true'; - }, -}; diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue new file mode 100644 index 00000000000..704dff981df --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -0,0 +1,66 @@ +<script> + import { mapState } from 'vuex'; + import icon from '../../../vue_shared/components/icon.vue'; + import listItem from './list_item.vue'; + import listCollapsed from './list_collapsed.vue'; + + export default { + components: { + icon, + listItem, + listCollapsed, + }, + props: { + title: { + type: String, + required: true, + }, + fileList: { + type: Array, + required: true, + }, + }, + computed: { + ...mapState([ + 'currentProjectId', + 'currentBranchId', + 'rightPanelCollapsed', + ]), + }, + methods: { + toggleCollapsed() { + this.$emit('toggleCollapsed'); + }, + }, + + }; +</script> + +<template> + <div class="multi-file-commit-list"> + <list-collapsed + v-if="rightPanelCollapsed" + /> + <template v-else> + <ul + v-if="fileList.length" + class="list-unstyled append-bottom-0" + > + <li + v-for="file in fileList" + :key="file.key" + > + <list-item + :file="file" + /> + </li> + </ul> + <div + v-else + class="help-block prepend-top-0" + > + No changes + </div> + </template> + </div> +</template> diff --git a/app/assets/javascripts/repo/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue index 6a0262f271b..6a0262f271b 100644 --- a/app/assets/javascripts/repo/components/commit_sidebar/list_collapsed.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue diff --git a/app/assets/javascripts/repo/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index 742f746e02f..742f746e02f 100644 --- a/app/assets/javascripts/repo/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue new file mode 100644 index 00000000000..7f29a355eca --- /dev/null +++ b/app/assets/javascripts/ide/components/ide.vue @@ -0,0 +1,73 @@ +<script> +import { mapState, mapGetters } from 'vuex'; +import ideSidebar from './ide_side_bar.vue'; +import ideContextbar from './ide_context_bar.vue'; +import repoTabs from './repo_tabs.vue'; +import repoFileButtons from './repo_file_buttons.vue'; +import ideStatusBar from './ide_status_bar.vue'; +import repoPreview from './repo_preview.vue'; +import repoEditor from './repo_editor.vue'; + +export default { + computed: { + ...mapState([ + 'currentBlobView', + 'selectedFile', + ]), + ...mapGetters([ + 'changedFiles', + 'activeFile', + ]), + }, + components: { + ideSidebar, + ideContextbar, + repoTabs, + repoFileButtons, + ideStatusBar, + repoEditor, + repoPreview, + }, + mounted() { + const returnValue = 'Are you sure you want to lose unsaved changes?'; + window.onbeforeunload = (e) => { + if (!this.changedFiles.length) return undefined; + + Object.assign(e, { + returnValue, + }); + return returnValue; + }; + }, +}; +</script> + +<template> + <div + class="ide-view" + > + <ide-sidebar/> + <div + class="multi-file-edit-pane" + > + <template + v-if="activeFile"> + <repo-tabs/> + <component + class="multi-file-edit-pane-content" + :is="currentBlobView" + /> + <repo-file-buttons/> + <ide-status-bar + :file="selectedFile"/> + </template> + <template + v-else> + <div class="ide-empty-state"> + <h2 class="clgray">Welcome to the GitLab IDE</h2> + </div> + </template> + </div> + <ide-contextbar/> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide_context_bar.vue b/app/assets/javascripts/ide/components/ide_context_bar.vue new file mode 100644 index 00000000000..5a08718e386 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_context_bar.vue @@ -0,0 +1,75 @@ +<script> +import { mapGetters, mapState, mapActions } from 'vuex'; +import repoCommitSection from './repo_commit_section.vue'; +import icon from '../../vue_shared/components/icon.vue'; + +export default { + components: { + repoCommitSection, + icon, + }, + computed: { + ...mapState([ + 'rightPanelCollapsed', + ]), + ...mapGetters([ + 'changedFiles', + ]), + currentIcon() { + return this.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right'; + }, + }, + methods: { + ...mapActions([ + 'setPanelCollapsedStatus', + ]), + toggleCollapsed() { + this.setPanelCollapsedStatus({ + side: 'right', + collapsed: !this.rightPanelCollapsed, + }); + }, + }, +}; +</script> + +<template> + <div + class="multi-file-commit-panel" + :class="{ + 'is-collapsed': rightPanelCollapsed, + }" + > + <div + class="multi-file-commit-panel-section"> + <header + class="multi-file-commit-panel-header" + :class="{ + 'is-collapsed': rightPanelCollapsed, + }" + > + <div + class="multi-file-commit-panel-header-title" + v-if="!rightPanelCollapsed"> + <icon + name="list-bulleted" + :size="18" + /> + Staged + </div> + <button + type="button" + class="btn btn-transparent multi-file-commit-panel-collapse-btn" + @click="toggleCollapsed" + > + <icon + :name="currentIcon" + :size="18" + /> + </button> + </header> + <repo-commit-section + class=""/> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide_project_branches_tree.vue b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue new file mode 100644 index 00000000000..bd3a521ff43 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue @@ -0,0 +1,47 @@ +<script> +import repoTree from './ide_repo_tree.vue'; +import icon from '../../vue_shared/components/icon.vue'; +import newDropdown from './new_dropdown/index.vue'; + +export default { + components: { + repoTree, + icon, + newDropdown, + }, + props: { + projectId: { + type: String, + required: true, + }, + branch: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div class="branch-container"> + <div class="branch-header"> + <div class="branch-header-title"> + <icon + name="branch" + :size="12"> + </icon> + {{ branch.name }} + </div> + <div class="branch-header-btns"> + <new-dropdown + :project-id="projectId" + :branch="branch.name" + path=""/> + </div> + </div> + <div> + <repo-tree + :treeId="branch.treeId"/> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide_project_tree.vue b/app/assets/javascripts/ide/components/ide_project_tree.vue new file mode 100644 index 00000000000..61daba6d176 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_project_tree.vue @@ -0,0 +1,47 @@ +<script> +import branchesTree from './ide_project_branches_tree.vue'; +import projectAvatarImage from '../../vue_shared/components/project_avatar/image.vue'; + +export default { + components: { + branchesTree, + projectAvatarImage, + }, + props: { + project: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div class="projects-sidebar"> + <div class="context-header"> + <a + :title="project.name" + :href="project.web_url"> + <div class="avatar-container s40 project-avatar"> + <project-avatar-image + class="avatar-container project-avatar" + :link-href="project.path" + :img-src="project.avatar_url" + :img-alt="project.name" + :img-size="40" + /> + </div> + <div class="sidebar-context-title"> + {{ project.name }} + </div> + </a> + </div> + <div class="multi-file-commit-panel-inner-scroll"> + <branches-tree + v-for="(branch, index) in project.branches" + :key="branch.name" + :project-id="project.path_with_namespace" + :branch="branch"/> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide_repo_tree.vue b/app/assets/javascripts/ide/components/ide_repo_tree.vue new file mode 100644 index 00000000000..b6b089e6b25 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_repo_tree.vue @@ -0,0 +1,66 @@ +<script> +import { mapState } from 'vuex'; +import RepoPreviousDirectory from './repo_prev_directory.vue'; +import RepoFile from './repo_file.vue'; +import RepoLoadingFile from './repo_loading_file.vue'; +import { treeList } from '../stores/utils'; + +export default { + components: { + 'repo-previous-directory': RepoPreviousDirectory, + 'repo-file': RepoFile, + 'repo-loading-file': RepoLoadingFile, + }, + props: { + treeId: { + type: String, + required: true, + }, + }, + computed: { + ...mapState([ + 'loading', + 'isRoot', + ]), + ...mapState({ + projectName(state) { + return state.project.name; + }, + }), + fetchedList() { + return treeList(this.$store.state, this.treeId); + }, + hasPreviousDirectory() { + return !this.isRoot && this.fetchedList.length; + }, + showLoading() { + return this.loading; + }, + }, +}; +</script> + +<template> +<div> + <div class="ide-file-list"> + <table class="table"> + <tbody + v-if="treeId"> + <repo-previous-directory + v-if="hasPreviousDirectory" + /> + <repo-loading-file + v-if="showLoading" + v-for="n in 5" + :key="n" + /> + <repo-file + v-for="file in fetchedList" + :key="file.key" + :file="file" + /> + </tbody> + </table> + </div> +</div> +</template> diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue new file mode 100644 index 00000000000..535398d98c2 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_side_bar.vue @@ -0,0 +1,62 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import projectTree from './ide_project_tree.vue'; +import icon from '../../vue_shared/components/icon.vue'; + +export default { + components: { + projectTree, + icon, + }, + computed: { + ...mapState([ + 'projects', + 'leftPanelCollapsed', + ]), + currentIcon() { + return this.leftPanelCollapsed ? 'angle-double-right' : 'angle-double-left'; + }, + }, + methods: { + ...mapActions([ + 'setPanelCollapsedStatus', + ]), + toggleCollapsed() { + this.setPanelCollapsedStatus({ + side: 'left', + collapsed: !this.leftPanelCollapsed, + }); + }, + }, +}; +</script> + +<template> + <div + class="multi-file-commit-panel" + :class="{ + 'is-collapsed': leftPanelCollapsed, + }" + > + <div class="multi-file-commit-panel-inner"> + <project-tree + v-for="(project, index) in projects" + :key="project.id" + :project="project"/> + </div> + <button + type="button" + class="btn btn-transparent left-collapse-btn" + @click="toggleCollapsed" + > + <icon + :name="currentIcon" + :size="18" + /> + <span + v-if="!leftPanelCollapsed" + class="collapse-text" + >Collapse sidebar</span> + </button> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue new file mode 100644 index 00000000000..a24abadd936 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -0,0 +1,71 @@ +<script> +import { mapState } from 'vuex'; +import icon from '../../vue_shared/components/icon.vue'; +import tooltip from '../../vue_shared/directives/tooltip'; +import timeAgoMixin from '../../vue_shared/mixins/timeago'; + +export default { + props: { + file: { + type: Object, + required: true, + }, + }, + components: { + icon, + }, + directives: { + tooltip, + }, + mixins: [ + timeAgoMixin, + ], + computed: { + ...mapState([ + 'selectedFile', + ]), + }, +}; +</script> + +<template> + <div + class="ide-status-bar"> + <div> + <icon + name="branch" + :size="12"> + </icon> + {{ selectedFile.branchId }} + </div> + <div> + <div + v-if="selectedFile.lastCommit && selectedFile.lastCommit.id"> + Last commit: + <a + v-tooltip + :title="selectedFile.lastCommit.message" + :href="selectedFile.lastCommit.url"> + {{ timeFormated(selectedFile.lastCommit.updatedAt) }} by + {{ selectedFile.lastCommit.author }} + </a> + </div> + </div> + <div + class="text-right"> + {{ selectedFile.name }} + </div> + <div + class="text-right"> + {{ selectedFile.eol }} + </div> + <div + class="text-right"> + {{ file.editorRow }}:{{ file.editorColumn }} + </div> + <div + class="text-right"> + {{ selectedFile.fileLanguage }} + </div> + </div> +</template> diff --git a/app/assets/javascripts/repo/components/new_branch_form.vue b/app/assets/javascripts/ide/components/new_branch_form.vue index ba7090e4a9d..2119d373d31 100644 --- a/app/assets/javascripts/repo/components/new_branch_form.vue +++ b/app/assets/javascripts/ide/components/new_branch_form.vue @@ -44,7 +44,7 @@ this.branchName = ''; if (this.dropdownText) { - this.dropdownText.textContent = this.currentBranch; + this.dropdownText.textContent = this.currentBranchId; } this.toggleDropdown(); diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue new file mode 100644 index 00000000000..6e67e99a70f --- /dev/null +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -0,0 +1,101 @@ +<script> + import newModal from './modal.vue'; + import upload from './upload.vue'; + import icon from '../../../vue_shared/components/icon.vue'; + + export default { + props: { + branch: { + type: String, + required: true, + }, + path: { + type: String, + required: true, + }, + parent: { + type: Object, + default: null, + }, + }, + components: { + icon, + newModal, + upload, + }, + data() { + return { + openModal: false, + modalType: '', + }; + }, + methods: { + createNewItem(type) { + this.modalType = type; + this.toggleModalOpen(); + }, + toggleModalOpen() { + this.openModal = !this.openModal; + }, + }, + }; +</script> + +<template> + <div class="repo-new-btn pull-right"> + <div class="dropdown"> + <button + type="button" + class="btn btn-sm btn-default dropdown-toggle add-to-tree" + data-toggle="dropdown" + aria-label="Create new file or directory" + > + <icon + name="plus" + :size="12" + css-classes="pull-left" + /> + <icon + name="arrow-down" + :size="12" + css-classes="pull-left" + /> + </button> + <ul class="dropdown-menu dropdown-menu-right"> + <li> + <a + href="#" + role="button" + @click.prevent="createNewItem('blob')" + > + {{ __('New file') }} + </a> + </li> + <li> + <upload + :branch-id="branch" + :path="path" + :parent="parent" + /> + </li> + <li> + <a + href="#" + role="button" + @click.prevent="createNewItem('tree')" + > + {{ __('New directory') }} + </a> + </li> + </ul> + </div> + <new-modal + v-if="openModal" + :type="modalType" + :branch-id="branch" + :path="path" + :parent="parent" + @toggle="toggleModalOpen" + /> + </div> +</template> diff --git a/app/assets/javascripts/repo/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index ac1f613bb71..a0650d37690 100644 --- a/app/assets/javascripts/repo/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -1,10 +1,18 @@ <script> - import { mapActions } from 'vuex'; + import { mapActions, mapState } from 'vuex'; import { __ } from '../../../locale'; - import popupDialog from '../../../vue_shared/components/popup_dialog.vue'; + import modal from '../../../vue_shared/components/modal.vue'; export default { props: { + branchId: { + type: String, + required: true, + }, + parent: { + type: Object, + default: null, + }, type: { type: String, required: true, @@ -20,7 +28,7 @@ }; }, components: { - popupDialog, + modal, }, methods: { ...mapActions([ @@ -28,6 +36,9 @@ ]), createEntryInStore() { this.createTempEntry({ + projectId: this.currentProjectId, + branchId: this.branchId, + parent: this.parent, name: this.entryName.replace(new RegExp(`^${this.path}/`), ''), type: this.type, }); @@ -39,6 +50,9 @@ }, }, computed: { + ...mapState([ + 'currentProjectId', + ]), modalTitle() { if (this.type === 'tree') { return __('Create new directory'); @@ -68,7 +82,7 @@ </script> <template> - <popup-dialog + <modal :title="modalTitle" :primary-button-label="buttonLabel" kind="success" @@ -94,5 +108,5 @@ </div> </fieldset> </form> - </popup-dialog> + </modal> </template> diff --git a/app/assets/javascripts/repo/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue index 14ad32f4ae0..2a2f2a241fc 100644 --- a/app/assets/javascripts/repo/components/new_dropdown/upload.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -1,12 +1,22 @@ <script> - import { mapActions } from 'vuex'; + import { mapActions, mapState } from 'vuex'; export default { props: { - path: { + branchId: { type: String, required: true, }, + parent: { + type: Object, + default: null, + }, + }, + computed: { + ...mapState([ + 'trees', + 'currentProjectId', + ]), }, methods: { ...mapActions([ @@ -22,6 +32,9 @@ this.createTempEntry({ name, + projectId: this.currentProjectId, + branchId: this.branchId, + parent: this.parent, type: 'blob', content: result, base64: !isText, @@ -42,6 +55,9 @@ openFile() { Array.from(this.$refs.fileUpload.files).forEach(file => this.readFile(file)); }, + startFileUpload() { + this.$refs.fileUpload.click(); + }, }, mounted() { this.$refs.fileUpload.addEventListener('change', this.openFile); @@ -53,16 +69,19 @@ </script> <template> - <label - role="button" - class="menu-item" - > - {{ __('Upload file') }} + <div> + <a + href="#" + role="button" + @click.prevent="startFileUpload" + > + {{ __('Upload file') }} + </a> <input id="file-upload" type="file" class="hidden" ref="fileUpload" /> - </label> + </div> </template> diff --git a/app/assets/javascripts/repo/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index d3344d0c8dc..470db2c9650 100644 --- a/app/assets/javascripts/repo/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -2,12 +2,12 @@ import { mapGetters, mapState, mapActions } from 'vuex'; import tooltip from '../../vue_shared/directives/tooltip'; import icon from '../../vue_shared/components/icon.vue'; -import PopupDialog from '../../vue_shared/components/popup_dialog.vue'; +import modal from '../../vue_shared/components/modal.vue'; import commitFilesList from './commit_sidebar/list.vue'; export default { components: { - PopupDialog, + modal, icon, commitFilesList, }, @@ -16,16 +16,17 @@ export default { }, data() { return { - showNewBranchDialog: false, + showNewBranchModal: false, submitCommitsLoading: false, startNewMR: false, commitMessage: '', - collapsed: true, }; }, computed: { ...mapState([ - 'currentBranch', + 'currentProjectId', + 'currentBranchId', + 'rightPanelCollapsed', ]), ...mapGetters([ 'changedFiles', @@ -42,12 +43,13 @@ export default { 'checkCommitStatus', 'commitChanges', 'getTreeData', + 'setPanelCollapsedStatus', ]), makeCommit(newBranch = false) { const createNewBranch = newBranch || this.startNewMR; const payload = { - branch: createNewBranch ? `${this.currentBranch}-${new Date().getTime().toString()}` : this.currentBranch, + branch: createNewBranch ? `${this.currentBranchId}-${new Date().getTime().toString()}` : this.currentBranchId, commit_message: this.commitMessage, actions: this.changedFiles.map(f => ({ action: f.tempFile ? 'create' : 'update', @@ -55,16 +57,21 @@ export default { content: f.content, encoding: f.base64 ? 'base64' : 'text', })), - start_branch: createNewBranch ? this.currentBranch : undefined, + start_branch: createNewBranch ? this.currentBranchId : undefined, }; - this.showNewBranchDialog = false; + this.showNewBranchModal = false; this.submitCommitsLoading = true; this.commitChanges({ payload, newMr: this.startNewMR }) .then(() => { this.submitCommitsLoading = false; - this.getTreeData(); + this.$store.dispatch('getTreeData', { + projectId: this.currentProjectId, + branch: this.currentBranchId, + endpoint: `/tree/${this.currentBranchId}`, + force: true, + }); }) .catch(() => { this.submitCommitsLoading = false; @@ -76,7 +83,7 @@ export default { this.checkCommitStatus() .then((branchChanged) => { if (branchChanged) { - this.showNewBranchDialog = true; + this.showNewBranchModal = true; } else { this.makeCommit(); } @@ -86,50 +93,36 @@ export default { }); }, toggleCollapsed() { - this.collapsed = !this.collapsed; + this.setPanelCollapsedStatus({ + side: 'right', + collapsed: !this.rightPanelCollapsed, + }); }, }, }; </script> <template> -<div - class="multi-file-commit-panel" - :class="{ - 'is-collapsed': collapsed, - }" -> - <popup-dialog - v-if="showNewBranchDialog" +<div class="multi-file-commit-panel-section"> + <modal + v-if="showNewBranchModal" :primary-button-label="__('Create new branch')" kind="primary" :title="__('Branch has changed')" :text="__('This branch has changed since you started editing. Would you like to create a new branch?')" - @toggle="showNewBranchDialog = false" + @toggle="showNewBranchModal = false" @submit="makeCommit(true)" /> - <button - v-if="collapsed" - type="button" - class="btn btn-transparent multi-file-commit-panel-collapse-btn is-collapsed prepend-top-10 append-bottom-10" - @click="toggleCollapsed" - > - <i - aria-hidden="true" - class="fa fa-angle-double-left" - > - </i> - </button> <commit-files-list title="Staged" :file-list="changedFiles" - :collapsed="collapsed" + :collapsed="rightPanelCollapsed" @toggleCollapsed="toggleCollapsed" /> <form class="form-horizontal multi-file-commit-form" @submit.prevent="tryCommit" - v-if="!collapsed" + v-if="!rightPanelCollapsed" > <div class="multi-file-commit-fieldset"> <textarea diff --git a/app/assets/javascripts/repo/components/repo_edit_button.vue b/app/assets/javascripts/ide/components/repo_edit_button.vue index 6c1bb4b8566..37bd9003e96 100644 --- a/app/assets/javascripts/repo/components/repo_edit_button.vue +++ b/app/assets/javascripts/ide/components/repo_edit_button.vue @@ -1,10 +1,10 @@ <script> import { mapGetters, mapActions, mapState } from 'vuex'; -import popupDialog from '../../vue_shared/components/popup_dialog.vue'; +import modal from '../../vue_shared/components/modal.vue'; export default { components: { - popupDialog, + modal, }, computed: { ...mapState([ @@ -43,7 +43,7 @@ export default { {{buttonLabel}} </span> </button> - <popup-dialog + <modal v-if="discardPopupOpen" class="text-left" :primary-button-label="__('Discard changes')" diff --git a/app/assets/javascripts/repo/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index f37cbd1e961..221be4b9074 100644 --- a/app/assets/javascripts/repo/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -1,6 +1,6 @@ <script> /* global monaco */ -import { mapGetters, mapActions } from 'vuex'; +import { mapState, mapGetters, mapActions } from 'vuex'; import flash from '../../flash'; import monacoLoader from '../monaco_loader'; import Editor from '../lib/editor'; @@ -24,6 +24,9 @@ export default { ...mapActions([ 'getRawFileData', 'changeFileContent', + 'setFileLanguage', + 'setEditorPosition', + 'setFileEOL', ]), initMonaco() { if (this.shouldHideEditor) return; @@ -43,12 +46,36 @@ export default { const model = this.editor.createModel(this.activeFile); this.editor.attachModel(model); + model.onChange((m) => { this.changeFileContent({ file: this.activeFile, content: m.getValue(), }); }); + + // Handle Cursor Position + this.editor.onPositionChange((instance, e) => { + this.setEditorPosition({ + editorRow: e.position.lineNumber, + editorColumn: e.position.column, + }); + }); + + this.editor.setPosition({ + lineNumber: this.activeFile.editorRow, + column: this.activeFile.editorColumn, + }); + + // Handle File Language + this.setFileLanguage({ + fileLanguage: model.language, + }); + + // Get File eol + this.setFileEOL({ + eol: model.eol, + }); }, }, watch: { @@ -57,12 +84,22 @@ export default { this.initMonaco(); } }, + leftPanelCollapsed() { + this.editor.updateDimensions(); + }, + rightPanelCollapsed() { + this.editor.updateDimensions(); + }, }, computed: { ...mapGetters([ 'activeFile', 'activeFileExtension', ]), + ...mapState([ + 'leftPanelCollapsed', + 'rightPanelCollapsed', + ]), shouldHideEditor() { return this.activeFile.binary && !this.activeFile.raw; }, @@ -76,13 +113,14 @@ export default { class="blob-viewer-container blob-editor-container" > <div - v-show="shouldHideEditor" + v-if="shouldHideEditor" v-html="activeFile.html" > </div> <div v-show="!shouldHideEditor" ref="editor" + class="multi-file-editor-holder" > </div> </div> diff --git a/app/assets/javascripts/repo/components/repo_file.vue b/app/assets/javascripts/ide/components/repo_file.vue index 75787ad6103..09ca11531b1 100644 --- a/app/assets/javascripts/repo/components/repo_file.vue +++ b/app/assets/javascripts/ide/components/repo_file.vue @@ -1,7 +1,8 @@ <script> - import { mapActions, mapGetters } from 'vuex'; + import { mapState } from 'vuex'; import timeAgoMixin from '../../vue_shared/mixins/timeago'; import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue'; + import newDropdown from './new_dropdown/index.vue'; export default { mixins: [ @@ -9,20 +10,22 @@ ], components: { skeletonLoadingContainer, + newDropdown, }, props: { file: { type: Object, required: true, }, + showExtraColumns: { + type: Boolean, + default: false, + }, }, computed: { - ...mapGetters([ - 'isCollapsed', + ...mapState([ + 'leftPanelCollapsed', ]), - isSubmodule() { - return this.file.type === 'submodule'; - }, fileIcon() { return { 'fa-spinner fa-spin': this.file.loading, @@ -30,6 +33,12 @@ 'fa-folder-open': !this.file.loading && this.file.opened, }; }, + isSubmodule() { + return this.file.type === 'submodule'; + }, + isTree() { + return this.file.type === 'tree'; + }, levelIndentation() { return { marginLeft: `${this.file.level * 16}px`, @@ -39,13 +48,39 @@ return this.file.id.substr(0, 8); }, submoduleColSpan() { - return !this.isCollapsed && this.isSubmodule ? 3 : 1; + return !this.leftPanelCollapsed && this.isSubmodule ? 3 : 1; + }, + fileClass() { + if (this.file.type === 'blob') { + if (this.file.active) { + return 'file-open file-active'; + } + return this.file.opened ? 'file-open' : ''; + } + return ''; + }, + changedClass() { + return { + 'fa-circle unsaved-icon': this.file.changed || this.file.tempFile, + }; }, }, methods: { - ...mapActions([ - 'clickedTreeRow', - ]), + clickFile(row) { + // Manual Action if a tree is selected/opened + if (this.file.type === 'tree' && this.$router.currentRoute.path === `/project${row.url}`) { + this.$store.dispatch('toggleTreeOpen', { + endpoint: this.file.url, + tree: this.file, + }); + } + this.$router.push(`/project${row.url}`); + }, + }, + updated() { + if (this.file.type === 'blob' && this.file.active) { + this.$el.scrollIntoView(); + } }, }; </script> @@ -53,7 +88,8 @@ <template> <tr class="file" - @click.prevent="clickedTreeRow(file)"> + :class="fileClass" + @click="clickFile(file)"> <td class="multi-file-table-name" :colspan="submoduleColSpan" @@ -66,11 +102,23 @@ > </i> <a - :href="file.url" class="repo-file-name" > {{ file.name }} </a> + <new-dropdown + v-if="isTree" + :project-id="file.projectId" + :branch="file.branchId" + :path="file.path" + :parent="file"/> + <i + class="fa" + v-if="changedClass" + :class="changedClass" + aria-hidden="true" + > + </i> <template v-if="isSubmodule && file.id"> @ <span class="commit-sha"> @@ -84,7 +132,7 @@ </template> </td> - <template v-if="!isCollapsed && !isSubmodule"> + <template v-if="showExtraColumns && !isSubmodule"> <td class="multi-file-table-col-commit-message hidden-sm hidden-xs"> <a v-if="file.lastCommit.message" diff --git a/app/assets/javascripts/repo/components/repo_file_buttons.vue b/app/assets/javascripts/ide/components/repo_file_buttons.vue index 34f0d51819a..34f0d51819a 100644 --- a/app/assets/javascripts/repo/components/repo_file_buttons.vue +++ b/app/assets/javascripts/ide/components/repo_file_buttons.vue diff --git a/app/assets/javascripts/repo/components/repo_loading_file.vue b/app/assets/javascripts/ide/components/repo_loading_file.vue index 8fa637d771f..7eb840c7608 100644 --- a/app/assets/javascripts/repo/components/repo_loading_file.vue +++ b/app/assets/javascripts/ide/components/repo_loading_file.vue @@ -1,5 +1,5 @@ <script> - import { mapGetters } from 'vuex'; + import { mapState } from 'vuex'; import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue'; export default { @@ -7,8 +7,8 @@ skeletonLoadingContainer, }, computed: { - ...mapGetters([ - 'isCollapsed', + ...mapState([ + 'leftPanelCollapsed', ]), }, }; @@ -24,7 +24,7 @@ :small="true" /> </td> - <template v-if="!isCollapsed"> + <template v-if="!leftPanelCollapsed"> <td class="hidden-sm hidden-xs"> <skeleton-loading-container diff --git a/app/assets/javascripts/repo/components/repo_prev_directory.vue b/app/assets/javascripts/ide/components/repo_prev_directory.vue index a2b305bbd05..7cd359ea4ed 100644 --- a/app/assets/javascripts/repo/components/repo_prev_directory.vue +++ b/app/assets/javascripts/ide/components/repo_prev_directory.vue @@ -1,16 +1,14 @@ <script> - import { mapGetters, mapState, mapActions } from 'vuex'; + import { mapState, mapActions } from 'vuex'; export default { computed: { ...mapState([ 'parentTreeUrl', - ]), - ...mapGetters([ - 'isCollapsed', + 'leftPanelCollapsed', ]), colSpanCondition() { - return this.isCollapsed ? undefined : 3; + return this.leftPanelCollapsed ? undefined : 3; }, }, methods: { diff --git a/app/assets/javascripts/repo/components/repo_preview.vue b/app/assets/javascripts/ide/components/repo_preview.vue index 425c55fafb5..3d1e0297bd5 100644 --- a/app/assets/javascripts/repo/components/repo_preview.vue +++ b/app/assets/javascripts/ide/components/repo_preview.vue @@ -1,6 +1,6 @@ <script> -/* global LineHighlighter */ import { mapGetters } from 'vuex'; +import LineHighlighter from '../../line_highlighter'; import syntaxHighlight from '../../syntax_highlight'; export default { diff --git a/app/assets/javascripts/repo/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue index fb29a60df66..5bd63ac9ec5 100644 --- a/app/assets/javascripts/repo/components/repo_tab.vue +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -27,16 +27,18 @@ export default { methods: { ...mapActions([ - 'setFileActive', 'closeFile', ]), + clickFile(tab) { + this.$router.push(`/project${tab.url}`); + }, }, }; </script> <template> <li - @click="setFileActive(tab)" + @click="clickFile(tab)" > <button type="button" diff --git a/app/assets/javascripts/repo/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue index ab0bef4f0ac..ab0bef4f0ac 100644 --- a/app/assets/javascripts/repo/components/repo_tabs.vue +++ b/app/assets/javascripts/ide/components/repo_tabs.vue diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js new file mode 100644 index 00000000000..a9cbf8e370f --- /dev/null +++ b/app/assets/javascripts/ide/ide_router.js @@ -0,0 +1,101 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import store from './stores'; +import flash from '../flash'; +import { + getTreeEntry, +} from './stores/utils'; + +Vue.use(VueRouter); + +/** + * Routes below /-/ide/: + +/project/h5bp/html5-boilerplate/blob/master +/project/h5bp/html5-boilerplate/blob/master/app/js/test.js + +/project/h5bp/html5-boilerplate/mr/123 +/project/h5bp/html5-boilerplate/mr/123/app/js/test.js + +/workspace/123 +/workspace/project/h5bp/html5-boilerplate/blob/my-special-branch +/workspace/project/h5bp/html5-boilerplate/mr/123 + +/ = /workspace + +/settings +*/ + +// Unfortunately Vue Router doesn't work without at least a fake component +// If you do only data handling +const EmptyRouterComponent = { + render(createElement) { + return createElement('div'); + }, +}; + +const router = new VueRouter({ + mode: 'history', + base: `${gon.relative_url_root}/-/ide/`, + routes: [ + { + path: '/project/:namespace/:project', + component: EmptyRouterComponent, + children: [ + { + path: ':targetmode/:branch/*', + component: EmptyRouterComponent, + }, + { + path: 'mr/:mrid', + component: EmptyRouterComponent, + }, + ], + }, + ], +}); + +router.beforeEach((to, from, next) => { + if (to.params.namespace && to.params.project) { + store.dispatch('getProjectData', { + namespace: to.params.namespace, + projectId: to.params.project, + }) + .then(() => { + const fullProjectId = `${to.params.namespace}/${to.params.project}`; + + if (to.params.branch) { + store.dispatch('getBranchData', { + projectId: fullProjectId, + branchId: to.params.branch, + }); + + store.dispatch('getTreeData', { + projectId: fullProjectId, + branch: to.params.branch, + endpoint: `/tree/${to.params.branch}`, + }) + .then(() => { + if (to.params[0]) { + const treeEntry = getTreeEntry(store, `${to.params.namespace}/${to.params.project}/${to.params.branch}`, to.params[0]); + if (treeEntry) { + store.dispatch('handleTreeEntryAction', treeEntry); + } + } + }) + .catch((e) => { + flash('Error while loading the branch files. Please try again.'); + throw e; + }); + } + }) + .catch((e) => { + flash('Error while loading the project data. Please try again.'); + throw e; + }); + } + + next(); +}); + +export default router; diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js new file mode 100644 index 00000000000..a96bd339f51 --- /dev/null +++ b/app/assets/javascripts/ide/index.js @@ -0,0 +1,55 @@ +import Vue from 'vue'; +import { mapActions } from 'vuex'; +import { convertPermissionToBoolean } from '../lib/utils/common_utils'; +import ide from './components/ide.vue'; + +import store from './stores'; +import router from './ide_router'; +import Translate from '../vue_shared/translate'; +import ContextualSidebar from '../contextual_sidebar'; + +function initIde(el) { + if (!el) return null; + + return new Vue({ + el, + store, + router, + components: { + ide, + }, + methods: { + ...mapActions([ + 'setInitialData', + ]), + }, + created() { + const data = el.dataset; + + this.setInitialData({ + endpoints: { + rootEndpoint: data.url, + newMergeRequestUrl: data.newMergeRequestUrl, + rootUrl: data.rootUrl, + }, + canCommit: convertPermissionToBoolean(data.canCommit), + onTopOfBranch: convertPermissionToBoolean(data.onTopOfBranch), + path: data.currentPath, + isRoot: convertPermissionToBoolean(data.root), + isInitialRoot: convertPermissionToBoolean(data.root), + }); + }, + render(createElement) { + return createElement('ide'); + }, + }); +} + +const ideElement = document.getElementById('ide'); + +Vue.use(Translate); + +initIde(ideElement); + +const contextualSidebar = new ContextualSidebar(); +contextualSidebar.bindEvents(); diff --git a/app/assets/javascripts/repo/lib/common/disposable.js b/app/assets/javascripts/ide/lib/common/disposable.js index 84b29bdb600..84b29bdb600 100644 --- a/app/assets/javascripts/repo/lib/common/disposable.js +++ b/app/assets/javascripts/ide/lib/common/disposable.js diff --git a/app/assets/javascripts/repo/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js index 23c4811e6c0..14d9fe4771e 100644 --- a/app/assets/javascripts/repo/lib/common/model.js +++ b/app/assets/javascripts/ide/lib/common/model.js @@ -28,6 +28,14 @@ export default class Model { return this.model.uri.toString(); } + get language() { + return this.model.getModeId(); + } + + get eol() { + return this.model.getEOL() === '\n' ? 'LF' : 'CRLF'; + } + get path() { return this.file.path; } diff --git a/app/assets/javascripts/repo/lib/common/model_manager.js b/app/assets/javascripts/ide/lib/common/model_manager.js index fd462252795..fd462252795 100644 --- a/app/assets/javascripts/repo/lib/common/model_manager.js +++ b/app/assets/javascripts/ide/lib/common/model_manager.js diff --git a/app/assets/javascripts/repo/lib/decorations/controller.js b/app/assets/javascripts/ide/lib/decorations/controller.js index 0954b7973c4..0954b7973c4 100644 --- a/app/assets/javascripts/repo/lib/decorations/controller.js +++ b/app/assets/javascripts/ide/lib/decorations/controller.js diff --git a/app/assets/javascripts/repo/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js index dc0b1c95e59..dc0b1c95e59 100644 --- a/app/assets/javascripts/repo/lib/diff/controller.js +++ b/app/assets/javascripts/ide/lib/diff/controller.js diff --git a/app/assets/javascripts/repo/lib/diff/diff.js b/app/assets/javascripts/ide/lib/diff/diff.js index 0e37f5c4704..0e37f5c4704 100644 --- a/app/assets/javascripts/repo/lib/diff/diff.js +++ b/app/assets/javascripts/ide/lib/diff/diff.js diff --git a/app/assets/javascripts/repo/lib/diff/diff_worker.js b/app/assets/javascripts/ide/lib/diff/diff_worker.js index e74c4046330..e74c4046330 100644 --- a/app/assets/javascripts/repo/lib/diff/diff_worker.js +++ b/app/assets/javascripts/ide/lib/diff/diff_worker.js diff --git a/app/assets/javascripts/repo/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index db499444402..51e202b9348 100644 --- a/app/assets/javascripts/repo/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -22,6 +22,11 @@ export default class Editor { this.modelManager = new ModelManager(this.monaco), this.decorationsController = new DecorationsController(this), ); + + this.debouncedUpdate = _.debounce(() => { + this.updateDimensions(); + }, 200); + window.addEventListener('resize', this.debouncedUpdate, false); } createInstance(domElement) { @@ -32,6 +37,9 @@ export default class Editor { readOnly: false, contextmenu: true, scrollBeyondLastLine: false, + minimap: { + enabled: false, + }, }), this.dirtyDiffController = new DirtyDiffController( this.modelManager, this.decorationsController, @@ -70,10 +78,32 @@ export default class Editor { dispose() { this.disposable.dispose(); + window.removeEventListener('resize', this.debouncedUpdate); // dispose main monaco instance if (this.instance) { this.instance = null; } } + + updateDimensions() { + this.instance.layout(); + } + + setPosition({ lineNumber, column }) { + this.instance.revealPositionInCenter({ + lineNumber, + column, + }); + this.instance.setPosition({ + lineNumber, + column, + }); + } + + onPositionChange(cb) { + this.disposable.add( + this.instance.onDidChangeCursorPosition(e => cb(this.instance, e)), + ); + } } diff --git a/app/assets/javascripts/repo/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js index 701affc466e..701affc466e 100644 --- a/app/assets/javascripts/repo/lib/editor_options.js +++ b/app/assets/javascripts/ide/lib/editor_options.js diff --git a/app/assets/javascripts/repo/monaco_loader.js b/app/assets/javascripts/ide/monaco_loader.js index af83a1ec0b4..af83a1ec0b4 100644 --- a/app/assets/javascripts/repo/monaco_loader.js +++ b/app/assets/javascripts/ide/monaco_loader.js diff --git a/app/assets/javascripts/repo/services/index.js b/app/assets/javascripts/ide/services/index.js index 994d325e991..1fb24e93f2e 100644 --- a/app/assets/javascripts/repo/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -23,8 +23,11 @@ export default { return Vue.http.get(file.rawPath, { params: { format: 'json' } }) .then(res => res.text()); }, - getBranchData(projectId, currentBranch) { - return Api.branchSingle(projectId, currentBranch); + getProjectData(namespace, project) { + return Api.project(`${namespace}/${project}`); + }, + getBranchData(projectId, currentBranchId) { + return Api.branchSingle(projectId, currentBranchId); }, createBranch(projectId, payload) { const url = Api.buildUrl(Api.createBranchPath).replace(':id', projectId); diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js new file mode 100644 index 00000000000..c01046c8c76 --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions.js @@ -0,0 +1,179 @@ +import Vue from 'vue'; +import { visitUrl } from '../../lib/utils/url_utility'; +import flash from '../../flash'; +import service from '../services'; +import * as types from './mutation_types'; + +export const redirectToUrl = (_, url) => visitUrl(url); + +export const setInitialData = ({ commit }, data) => + commit(types.SET_INITIAL_DATA, data); + +export const closeDiscardPopup = ({ commit }) => + commit(types.TOGGLE_DISCARD_POPUP, false); + +export const discardAllChanges = ({ commit, getters, dispatch }) => { + const changedFiles = getters.changedFiles; + + changedFiles.forEach((file) => { + commit(types.DISCARD_FILE_CHANGES, file); + + if (file.tempFile) { + dispatch('closeFile', { file, force: true }); + } + }); +}; + +export const closeAllFiles = ({ state, dispatch }) => { + state.openFiles.forEach(file => dispatch('closeFile', { file })); +}; + +export const toggleEditMode = ( + { state, commit, getters, dispatch }, + force = false, +) => { + const changedFiles = getters.changedFiles; + + if (changedFiles.length && !force) { + commit(types.TOGGLE_DISCARD_POPUP, true); + } else { + commit(types.TOGGLE_EDIT_MODE); + commit(types.TOGGLE_DISCARD_POPUP, false); + dispatch('toggleBlobView'); + + if (!state.editMode) { + dispatch('discardAllChanges'); + } + } +}; + +export const toggleBlobView = ({ commit, state }) => { + if (state.editMode) { + commit(types.SET_EDIT_MODE); + } else { + commit(types.SET_PREVIEW_MODE); + } +}; + +export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => { + if (side === 'left') { + commit(types.SET_LEFT_PANEL_COLLAPSED, collapsed); + } else { + commit(types.SET_RIGHT_PANEL_COLLAPSED, collapsed); + } +}; + +export const checkCommitStatus = ({ state }) => + service + .getBranchData(state.currentProjectId, state.currentBranchId) + .then((data) => { + const { id } = data.commit; + const selectedBranch = + state.projects[state.currentProjectId].branches[state.currentBranchId]; + + if (selectedBranch.workingReference !== id) { + return true; + } + + return false; + }) + .catch(() => flash('Error checking branch data. Please try again.')); + +export const commitChanges = ( + { commit, state, dispatch, getters }, + { payload, newMr }, +) => + service + .commit(state.currentProjectId, payload) + .then((data) => { + const { branch } = payload; + if (!data.short_id) { + flash(data.message); + return; + } + + const selectedProject = state.projects[state.currentProjectId]; + const lastCommit = { + commit_path: `${selectedProject.web_url}/commit/${data.id}`, + commit: { + message: data.message, + authored_date: data.committed_date, + }, + }; + + flash( + `Your changes have been committed. Commit ${data.short_id} with ${ + data.stats.additions + } additions, ${data.stats.deletions} deletions.`, + 'notice', + ); + + if (newMr) { + dispatch( + 'redirectToUrl', + `${ + selectedProject.web_url + }/merge_requests/new?merge_request%5Bsource_branch%5D=${branch}`, + ); + } else { + commit(types.SET_BRANCH_WORKING_REFERENCE, { + projectId: state.currentProjectId, + branchId: state.currentBranchId, + reference: data.id, + }); + + getters.changedFiles.forEach((entry) => { + commit(types.SET_LAST_COMMIT_DATA, { + entry, + lastCommit, + }); + }); + + dispatch('discardAllChanges'); + dispatch('closeAllFiles'); + + window.scrollTo(0, 0); + } + }) + .catch(() => flash('Error committing changes. Please try again.')); + +export const createTempEntry = ( + { state, dispatch }, + { projectId, branchId, parent, name, type, content = '', base64 = false }, +) => { + const selectedParent = parent || state.trees[`${projectId}/${branchId}`]; + if (type === 'tree') { + dispatch('createTempTree', { + projectId, + branchId, + parent: selectedParent, + name, + }); + } else if (type === 'blob') { + dispatch('createTempFile', { + projectId, + branchId, + parent: selectedParent, + name, + base64, + content, + }); + } +}; + +export const scrollToTab = () => { + Vue.nextTick(() => { + const tabs = document.getElementById('tabs'); + + if (tabs) { + const tabEl = tabs.querySelector('.active .repo-tab'); + + tabEl.focus(); + } + }); +}; + +export * from './actions/tree'; +export * from './actions/file'; +export * from './actions/project'; +export * from './actions/branch'; diff --git a/app/assets/javascripts/ide/stores/actions/branch.js b/app/assets/javascripts/ide/stores/actions/branch.js new file mode 100644 index 00000000000..32bdf7fec22 --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions/branch.js @@ -0,0 +1,43 @@ +import service from '../../services'; +import flash from '../../../flash'; +import * as types from '../mutation_types'; + +export const getBranchData = ( + { commit, state, dispatch }, + { projectId, branchId, force = false } = {}, +) => new Promise((resolve, reject) => { + if ((typeof state.projects[`${projectId}`] === 'undefined' || + !state.projects[`${projectId}`].branches[branchId]) + || force) { + service.getBranchData(`${projectId}`, branchId) + .then((data) => { + const { id } = data.commit; + commit(types.SET_BRANCH, { projectPath: `${projectId}`, branchName: branchId, branch: data }); + commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id }); + resolve(data); + }) + .catch(() => { + flash('Error loading branch data. Please try again.'); + reject(new Error(`Branch not loaded - ${projectId}/${branchId}`)); + }); + } else { + resolve(state.projects[`${projectId}`].branches[branchId]); + } +}); + +export const createNewBranch = ({ state, commit }, branch) => service.createBranch( + state.currentProjectId, + { + branch, + ref: state.currentBranchId, + }, +) +.then(res => res.json()) +.then((data) => { + const branchName = data.name; + const url = location.href.replace(state.currentBranchId, branchName); + + if (this.$router) this.$router.push(url); + + commit(types.SET_CURRENT_BRANCH, branchName); +}); diff --git a/app/assets/javascripts/repo/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 5bae4fa826a..0f27d5bf1c3 100644 --- a/app/assets/javascripts/repo/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -2,9 +2,9 @@ import { normalizeHeaders } from '../../../lib/utils/common_utils'; import flash from '../../../flash'; import service from '../../services'; import * as types from '../mutation_types'; +import router from '../../ide_router'; import { findEntry, - pushState, setPageTitle, createTemp, findIndexOfFile, @@ -25,7 +25,7 @@ export const closeFile = ({ commit, state, dispatch }, { file, force = false }) dispatch('setFileActive', nextFileToOpen); } else if (!state.openFiles.length) { - pushState(file.parentTreeUrl); + router.push(`/project/${file.projectId}/tree/${file.branchId}/`); } dispatch('getLastCommitData'); @@ -45,6 +45,9 @@ export const setFileActive = ({ commit, state, getters, dispatch }, file) => { // reset hash for line highlighting location.hash = ''; + + commit(types.SET_CURRENT_PROJECT, file.projectId); + commit(types.SET_CURRENT_BRANCH, file.branchId); }; export const getFileData = ({ state, commit, dispatch }, file) => { @@ -63,8 +66,6 @@ export const getFileData = ({ state, commit, dispatch }, file) => { commit(types.TOGGLE_FILE_OPEN, file); dispatch('setFileActive', file); commit(types.TOGGLE_LOADING, file); - - pushState(file.url); }) .catch(() => { commit(types.TOGGLE_LOADING, file); @@ -82,21 +83,39 @@ export const changeFileContent = ({ commit }, { file, content }) => { commit(types.UPDATE_FILE_CONTENT, { file, content }); }; -export const createTempFile = ({ state, commit, dispatch }, { tree, name, content = '', base64 = '' }) => { +export const setFileLanguage = ({ state, commit }, { fileLanguage }) => { + commit(types.SET_FILE_LANGUAGE, { file: state.selectedFile, fileLanguage }); +}; + +export const setFileEOL = ({ state, commit }, { eol }) => { + commit(types.SET_FILE_EOL, { file: state.selectedFile, eol }); +}; + +export const setEditorPosition = ({ state, commit }, { editorRow, editorColumn }) => { + commit(types.SET_FILE_POSITION, { file: state.selectedFile, editorRow, editorColumn }); +}; + +export const createTempFile = ({ state, commit, dispatch }, { projectId, branchId, parent, name, content = '', base64 = '' }) => { + const path = parent.path !== undefined ? parent.path : ''; + // We need to do the replacement otherwise the web_url + file.url duplicate + const newUrl = `/${projectId}/blob/${branchId}/${path}${path ? '/' : ''}${name}`; const file = createTemp({ - name: name.replace(`${state.path}/`, ''), - path: tree.path, + projectId, + branchId, + name: name.replace(`${path}/`, ''), + path, type: 'blob', - level: tree.level !== undefined ? tree.level + 1 : 0, + level: parent.level !== undefined ? parent.level + 1 : 0, changed: true, content, base64, + url: newUrl, }); - if (findEntry(tree, 'blob', file.name)) return flash(`The name "${file.name}" is already taken in this directory.`); + if (findEntry(parent.tree, 'blob', file.name)) return flash(`The name "${file.name}" is already taken in this directory.`); commit(types.CREATE_TMP_FILE, { - parent: tree, + parent, file, }); commit(types.TOGGLE_FILE_OPEN, file); @@ -106,5 +125,7 @@ export const createTempFile = ({ state, commit, dispatch }, { tree, name, conten dispatch('toggleEditMode', true); } + router.push(`/project${file.url}`); + return Promise.resolve(file); }; diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js new file mode 100644 index 00000000000..75e332090cb --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -0,0 +1,25 @@ +import service from '../../services'; +import flash from '../../../flash'; +import * as types from '../mutation_types'; + +// eslint-disable-next-line import/prefer-default-export +export const getProjectData = ( + { commit, state, dispatch }, + { namespace, projectId, force = false } = {}, +) => new Promise((resolve, reject) => { + if (!state.projects[`${namespace}/${projectId}`] || force) { + service.getProjectData(namespace, projectId) + .then(res => res.data) + .then((data) => { + commit(types.SET_PROJECT, { projectPath: `${namespace}/${projectId}`, project: data }); + if (!state.currentProjectId) commit(types.SET_CURRENT_PROJECT, `${namespace}/${projectId}`); + resolve(data); + }) + .catch(() => { + flash('Error loading project data. Please try again.'); + reject(new Error(`Project not loaded ${namespace}/${projectId}`)); + }); + } else { + resolve(state.projects[`${namespace}/${projectId}`]); + } +}); diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js new file mode 100644 index 00000000000..25909400a75 --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -0,0 +1,188 @@ +import { visitUrl } from '../../../lib/utils/url_utility'; +import { normalizeHeaders } from '../../../lib/utils/common_utils'; +import flash from '../../../flash'; +import service from '../../services'; +import * as types from '../mutation_types'; +import router from '../../ide_router'; +import { + setPageTitle, + findEntry, + createTemp, + createOrMergeEntry, +} from '../utils'; + +export const getTreeData = ( + { commit, state, dispatch }, + { endpoint, tree = null, projectId, branch, force = false } = {}, +) => new Promise((resolve, reject) => { + // We already have the base tree so we resolve immediately + if (!tree && state.trees[`${projectId}/${branch}`] && !force) { + resolve(); + } else { + if (tree) commit(types.TOGGLE_LOADING, tree); + const selectedProject = state.projects[projectId]; + // We are merging the web_url that we got on the project info with the endpoint + // we got on the tree entry, as both contain the projectId, we replace it in the tree endpoint + const completeEndpoint = selectedProject.web_url + (endpoint).replace(projectId, ''); + if (completeEndpoint && (!tree || !tree.tempFile)) { + service.getTreeData(completeEndpoint) + .then((res) => { + const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']); + + setPageTitle(pageTitle); + + return res.json(); + }) + .then((data) => { + if (!state.isInitialRoot) { + commit(types.SET_ROOT, data.path === '/'); + } + + dispatch('updateDirectoryData', { data, tree, projectId, branch }); + const selectedTree = tree || state.trees[`${projectId}/${branch}`]; + + commit(types.SET_PARENT_TREE_URL, data.parent_tree_url); + commit(types.SET_LAST_COMMIT_URL, { tree: selectedTree, url: data.last_commit_path }); + if (tree) commit(types.TOGGLE_LOADING, selectedTree); + + const prevLastCommitPath = selectedTree.lastCommitPath; + if (prevLastCommitPath !== null) { + dispatch('getLastCommitData', selectedTree); + } + resolve(data); + }) + .catch((e) => { + flash('Error loading tree data. Please try again.'); + if (tree) commit(types.TOGGLE_LOADING, tree); + reject(e); + }); + } else { + resolve(); + } + } +}); + +export const toggleTreeOpen = ({ commit, dispatch }, { endpoint, tree }) => { + if (tree.opened) { + // send empty data to clear the tree + const data = { trees: [], blobs: [], submodules: [] }; + + dispatch('updateDirectoryData', { data, tree, projectId: tree.projectId, branchId: tree.branchId }); + } else { + dispatch('getTreeData', { endpoint, tree, projectId: tree.projectId, branch: tree.branchId }); + } + + commit(types.TOGGLE_TREE_OPEN, tree); +}; + +export const handleTreeEntryAction = ({ commit, dispatch }, row) => { + if (row.type === 'tree') { + dispatch('toggleTreeOpen', { + endpoint: row.url, + tree: row, + }); + } else if (row.type === 'submodule') { + commit(types.TOGGLE_LOADING, row); + visitUrl(row.url); + } else if (row.type === 'blob' && row.opened) { + dispatch('setFileActive', row); + } else { + dispatch('getFileData', row); + } +}; + +export const createTempTree = ( + { state, commit, dispatch }, + { projectId, branchId, parent, name }, +) => { + let selectedTree = parent; + const dirNames = name.replace(new RegExp(`^${state.path}/`), '').split('/'); + + dirNames.forEach((dirName) => { + const foundEntry = findEntry(selectedTree.tree, 'tree', dirName); + + if (!foundEntry) { + const path = selectedTree.path !== undefined ? selectedTree.path : ''; + const tmpEntry = createTemp({ + projectId, + branchId, + name: dirName, + path, + type: 'tree', + level: selectedTree.level !== undefined ? selectedTree.level + 1 : 0, + tree: [], + url: `/${projectId}/blob/${branchId}/${path}${path ? '/' : ''}${dirName}`, + }); + + commit(types.CREATE_TMP_TREE, { + parent: selectedTree, + tmpEntry, + }); + commit(types.TOGGLE_TREE_OPEN, tmpEntry); + + router.push(`/project${tmpEntry.url}`); + + selectedTree = tmpEntry; + } else { + selectedTree = foundEntry; + } + }); +}; + +export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = state) => { + if (!tree || tree.lastCommitPath === null || !tree.lastCommitPath) return; + + service.getTreeLastCommit(tree.lastCommitPath) + .then((res) => { + const lastCommitPath = normalizeHeaders(res.headers)['MORE-LOGS-URL'] || null; + + commit(types.SET_LAST_COMMIT_URL, { tree, url: lastCommitPath }); + + return res.json(); + }) + .then((data) => { + data.forEach((lastCommit) => { + const entry = findEntry(tree.tree, lastCommit.type, lastCommit.file_name); + + if (entry) { + commit(types.SET_LAST_COMMIT_DATA, { entry, lastCommit }); + } + }); + + dispatch('getLastCommitData', tree); + }) + .catch(() => flash('Error fetching log data.')); +}; + +export const updateDirectoryData = ( + { commit, state }, + { data, tree, projectId, branch }, +) => { + if (!tree) { + const existingTree = state.trees[`${projectId}/${branch}`]; + if (!existingTree) { + commit(types.CREATE_TREE, { treePath: `${projectId}/${branch}` }); + } + } + + const selectedTree = tree || state.trees[`${projectId}/${branch}`]; + const level = selectedTree.level !== undefined ? selectedTree.level + 1 : 0; + const parentTreeUrl = data.parent_tree_url ? `${data.parent_tree_url}${data.path}` : state.endpoints.rootUrl; + const createEntry = (entry, type) => createOrMergeEntry({ + tree: selectedTree, + projectId: `${projectId}`, + branchId: branch, + entry, + level, + type, + parentTreeUrl, + }); + + const formattedData = [ + ...data.trees.map(t => createEntry(t, 'tree')), + ...data.submodules.map(m => createEntry(m, 'submodule')), + ...data.blobs.map(b => createEntry(b, 'blob')), + ]; + + commit(types.SET_DIRECTORY_DATA, { tree: selectedTree, data: formattedData }); +}; diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js new file mode 100644 index 00000000000..6b51ccff817 --- /dev/null +++ b/app/assets/javascripts/ide/stores/getters.js @@ -0,0 +1,19 @@ +export const changedFiles = state => state.openFiles.filter(file => file.changed); + +export const activeFile = state => state.openFiles.find(file => file.active) || null; + +export const activeFileExtension = (state) => { + const file = activeFile(state); + return file ? `.${file.path.split('.').pop()}` : ''; +}; + +export const canEditFile = (state) => { + const currentActiveFile = activeFile(state); + + return state.canCommit && + (currentActiveFile && !currentActiveFile.renderError && !currentActiveFile.binary); +}; + +export const addedFiles = state => changedFiles(state).filter(f => f.tempFile); + +export const modifiedFiles = state => changedFiles(state).filter(f => !f.tempFile); diff --git a/app/assets/javascripts/repo/stores/index.js b/app/assets/javascripts/ide/stores/index.js index 6ac9bfd8189..6ac9bfd8189 100644 --- a/app/assets/javascripts/repo/stores/index.js +++ b/app/assets/javascripts/ide/stores/index.js diff --git a/app/assets/javascripts/repo/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index bc3390f1506..4e3c10972ba 100644 --- a/app/assets/javascripts/repo/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -1,16 +1,27 @@ export const SET_INITIAL_DATA = 'SET_INITIAL_DATA'; export const TOGGLE_LOADING = 'TOGGLE_LOADING'; -export const SET_COMMIT_REF = 'SET_COMMIT_REF'; export const SET_PARENT_TREE_URL = 'SET_PARENT_TREE_URL'; export const SET_ROOT = 'SET_ROOT'; -export const SET_PREVIOUS_URL = 'SET_PREVIOUS_URL'; export const SET_LAST_COMMIT_DATA = 'SET_LAST_COMMIT_DATA'; +export const SET_LEFT_PANEL_COLLAPSED = 'SET_LEFT_PANEL_COLLAPSED'; +export const SET_RIGHT_PANEL_COLLAPSED = 'SET_RIGHT_PANEL_COLLAPSED'; + +// Project Mutation Types +export const SET_PROJECT = 'SET_PROJECT'; +export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT'; +export const TOGGLE_PROJECT_OPEN = 'TOGGLE_PROJECT_OPEN'; + +// Branch Mutation Types +export const SET_BRANCH = 'SET_BRANCH'; +export const SET_BRANCH_WORKING_REFERENCE = 'SET_BRANCH_WORKING_REFERENCE'; +export const TOGGLE_BRANCH_OPEN = 'TOGGLE_BRANCH_OPEN'; // Tree mutation types export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA'; export const TOGGLE_TREE_OPEN = 'TOGGLE_TREE_OPEN'; export const CREATE_TMP_TREE = 'CREATE_TMP_TREE'; export const SET_LAST_COMMIT_URL = 'SET_LAST_COMMIT_URL'; +export const CREATE_TREE = 'CREATE_TREE'; // File mutation types export const SET_FILE_DATA = 'SET_FILE_DATA'; @@ -18,6 +29,9 @@ export const TOGGLE_FILE_OPEN = 'TOGGLE_FILE_OPEN'; export const SET_FILE_ACTIVE = 'SET_FILE_ACTIVE'; export const SET_FILE_RAW_DATA = 'SET_FILE_RAW_DATA'; export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT'; +export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE'; +export const SET_FILE_POSITION = 'SET_FILE_POSITION'; +export const SET_FILE_EOL = 'SET_FILE_EOL'; export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES'; export const CREATE_TMP_FILE = 'CREATE_TMP_FILE'; @@ -28,3 +42,4 @@ export const TOGGLE_EDIT_MODE = 'TOGGLE_EDIT_MODE'; export const TOGGLE_DISCARD_POPUP = 'TOGGLE_DISCARD_POPUP'; export const SET_CURRENT_BRANCH = 'SET_CURRENT_BRANCH'; + diff --git a/app/assets/javascripts/repo/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index ae2ba5bedf7..2fed9019cb6 100644 --- a/app/assets/javascripts/repo/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -1,4 +1,5 @@ import * as types from './mutation_types'; +import projectMutations from './mutations/project'; import fileMutations from './mutations/file'; import treeMutations from './mutations/tree'; import branchMutations from './mutations/branch'; @@ -32,29 +33,32 @@ export default { discardPopupOpen, }); }, - [types.SET_COMMIT_REF](state, ref) { - Object.assign(state, { - currentRef: ref, - }); - }, [types.SET_ROOT](state, isRoot) { Object.assign(state, { isRoot, isInitialRoot: isRoot, }); }, - [types.SET_PREVIOUS_URL](state, previousUrl) { + [types.SET_LEFT_PANEL_COLLAPSED](state, collapsed) { + Object.assign(state, { + leftPanelCollapsed: collapsed, + }); + }, + [types.SET_RIGHT_PANEL_COLLAPSED](state, collapsed) { Object.assign(state, { - previousUrl, + rightPanelCollapsed: collapsed, }); }, [types.SET_LAST_COMMIT_DATA](state, { entry, lastCommit }) { Object.assign(entry.lastCommit, { + id: lastCommit.commit.id, url: lastCommit.commit_path, message: lastCommit.commit.message, + author: lastCommit.commit.author_name, updatedAt: lastCommit.commit.authored_date, }); }, + ...projectMutations, ...fileMutations, ...treeMutations, ...branchMutations, diff --git a/app/assets/javascripts/ide/stores/mutations/branch.js b/app/assets/javascripts/ide/stores/mutations/branch.js new file mode 100644 index 00000000000..04b9582c5bb --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations/branch.js @@ -0,0 +1,28 @@ +import * as types from '../mutation_types'; + +export default { + [types.SET_CURRENT_BRANCH](state, currentBranchId) { + Object.assign(state, { + currentBranchId, + }); + }, + [types.SET_BRANCH](state, { projectPath, branchName, branch }) { + // Add client side properties + Object.assign(branch, { + treeId: `${projectPath}/${branchName}`, + active: true, + workingReference: '', + }); + + Object.assign(state.projects[projectPath], { + branches: { + [branchName]: branch, + }, + }); + }, + [types.SET_BRANCH_WORKING_REFERENCE](state, { projectId, branchId, reference }) { + Object.assign(state.projects[projectId].branches[branchId], { + workingReference: reference, + }); + }, +}; diff --git a/app/assets/javascripts/repo/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index f9ba80b9dc2..5f3655b0092 100644 --- a/app/assets/javascripts/repo/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -6,6 +6,10 @@ export default { Object.assign(file, { active, }); + + Object.assign(state, { + selectedFile: file, + }); }, [types.TOGGLE_FILE_OPEN](state, file) { Object.assign(file, { @@ -42,6 +46,22 @@ export default { changed, }); }, + [types.SET_FILE_LANGUAGE](state, { file, fileLanguage }) { + Object.assign(file, { + fileLanguage, + }); + }, + [types.SET_FILE_EOL](state, { file, eol }) { + Object.assign(file, { + eol, + }); + }, + [types.SET_FILE_POSITION](state, { file, editorRow, editorColumn }) { + Object.assign(file, { + editorRow, + editorColumn, + }); + }, [types.DISCARD_FILE_CHANGES](state, file) { Object.assign(file, { content: '', diff --git a/app/assets/javascripts/ide/stores/mutations/project.js b/app/assets/javascripts/ide/stores/mutations/project.js new file mode 100644 index 00000000000..2816562a919 --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations/project.js @@ -0,0 +1,23 @@ +import * as types from '../mutation_types'; + +export default { + [types.SET_CURRENT_PROJECT](state, currentProjectId) { + Object.assign(state, { + currentProjectId, + }); + }, + [types.SET_PROJECT](state, { projectPath, project }) { + // Add client side properties + Object.assign(project, { + tree: [], + branches: {}, + active: true, + }); + + Object.assign(state, { + projects: Object.assign({}, state.projects, { + [projectPath]: project, + }), + }); + }, +}; diff --git a/app/assets/javascripts/repo/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js index 130221c9fda..4fe438ab465 100644 --- a/app/assets/javascripts/repo/stores/mutations/tree.js +++ b/app/assets/javascripts/ide/stores/mutations/tree.js @@ -6,6 +6,15 @@ export default { opened: !tree.opened, }); }, + [types.CREATE_TREE](state, { treePath }) { + Object.assign(state, { + trees: Object.assign({}, state.trees, { + [treePath]: { + tree: [], + }, + }), + }); + }, [types.SET_DIRECTORY_DATA](state, { data, tree }) { Object.assign(tree, { tree: data, diff --git a/app/assets/javascripts/repo/stores/state.js b/app/assets/javascripts/ide/stores/state.js index 0068834831e..539e382830f 100644 --- a/app/assets/javascripts/repo/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -1,10 +1,10 @@ export default () => ({ canCommit: false, - currentBranch: '', - currentBlobView: 'repo-preview', - currentRef: '', + currentProjectId: '', + currentBranchId: '', + currentBlobView: 'repo-editor', discardPopupOpen: false, - editMode: false, + editMode: true, endpoints: {}, isRoot: false, isInitialRoot: false, @@ -12,13 +12,11 @@ export default () => ({ loading: false, onTopOfBranch: false, openFiles: [], + selectedFile: null, path: '', - project: { - id: 0, - name: '', - url: '', - }, parentTreeUrl: '', - previousUrl: '', - tree: [], + trees: {}, + projects: {}, + leftPanelCollapsed: false, + rightPanelCollapsed: true, }); diff --git a/app/assets/javascripts/repo/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index fae1f4439a9..29e3ab5d040 100644 --- a/app/assets/javascripts/repo/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -2,6 +2,8 @@ export const dataStructure = () => ({ id: '', key: '', type: '', + projectId: '', + branchId: '', name: '', url: '', path: '', @@ -15,9 +17,11 @@ export const dataStructure = () => ({ changed: false, lastCommitPath: '', lastCommit: { + id: '', url: '', message: '', updatedAt: '', + author: '', }, tree_url: '', blamePath: '', @@ -31,11 +35,17 @@ export const dataStructure = () => ({ parentTreeUrl: '', renderError: false, base64: false, + editorRow: 1, + editorColumn: 1, + fileLanguage: '', + eol: '', }); export const decorateData = (entity) => { const { id, + projectId, + branchId, type, url, name, @@ -56,6 +66,8 @@ export const decorateData = (entity) => { return { ...dataStructure(), id, + projectId, + branchId, key: `${name}-${type}-${id}`, type, name, @@ -75,24 +87,51 @@ export const decorateData = (entity) => { }; }; -export const findEntry = (state, type, name) => state.tree.find( +/* + Takes the multi-dimensional tree and returns a flattened array. + This allows for the table to recursively render the table rows but keeps the data + structure nested to make it easier to add new files/directories. +*/ +export const treeList = (state, treeId) => { + const baseTree = state.trees[treeId]; + if (baseTree) { + const mapTree = arr => (!arr.tree || !arr.tree.length ? + [] : _.map(arr.tree, a => [a, mapTree(a)])); + + return _.chain(baseTree.tree) + .map(arr => [arr, mapTree(arr)]) + .flatten() + .value(); + } + return []; +}; + +export const getTree = state => (namespace, projectId, branch) => state.trees[`${namespace}/${projectId}/${branch}`]; + +export const getTreeEntry = (store, treeId, path) => { + const fileList = treeList(store.state, treeId); + return fileList ? fileList.find(file => file.path === path) : null; +}; + +export const findEntry = (tree, type, name) => tree.find( f => f.type === type && f.name === name, ); + export const findIndexOfFile = (state, file) => state.findIndex(f => f.path === file.path); export const setPageTitle = (title) => { document.title = title; }; -export const pushState = (url) => { - history.pushState({ url }, '', url); -}; - -export const createTemp = ({ name, path, type, level, changed, content, base64 }) => { +export const createTemp = ({ + projectId, branchId, name, path, type, level, changed, content, base64, url, +}) => { const treePath = path ? `${path}/${name}` : name; return decorateData({ id: new Date().getTime().toString(), + projectId, + branchId, name, type, tempFile: true, @@ -104,11 +143,18 @@ export const createTemp = ({ name, path, type, level, changed, content, base64 } level, base64, renderError: base64, + url, }); }; -export const createOrMergeEntry = ({ tree, entry, type, parentTreeUrl, level }) => { - const found = findEntry(tree, type, entry.name); +export const createOrMergeEntry = ({ tree, + projectId, + branchId, + entry, + type, + parentTreeUrl, + level }) => { + const found = findEntry(tree.tree || tree, type, entry.name); if (found) { return Object.assign({}, found, { @@ -120,6 +166,8 @@ export const createOrMergeEntry = ({ tree, entry, type, parentTreeUrl, level }) return decorateData({ ...entry, + projectId, + branchId, type, parentTreeUrl, level, diff --git a/app/assets/javascripts/init_issuable_sidebar.js b/app/assets/javascripts/init_issuable_sidebar.js index ada693afc46..5d4c1851fe5 100644 --- a/app/assets/javascripts/init_issuable_sidebar.js +++ b/app/assets/javascripts/init_issuable_sidebar.js @@ -2,7 +2,7 @@ /* global MilestoneSelect */ import LabelsSelect from './labels_select'; import IssuableContext from './issuable_context'; -/* global Sidebar */ +import Sidebar from './right_sidebar'; import DueDateSelectors from './due_date_select'; @@ -15,5 +15,5 @@ export default () => { new LabelsSelect(); new IssuableContext(sidebarOptions.currentUser); new DueDateSelectors(); - window.sidebar = new Sidebar(); + Sidebar.initialize(); }; diff --git a/app/assets/javascripts/init_notes.js b/app/assets/javascripts/init_notes.js index 3a8b4360cb6..882aedfcc76 100644 --- a/app/assets/javascripts/init_notes.js +++ b/app/assets/javascripts/init_notes.js @@ -1,4 +1,4 @@ -/* global Notes */ +import Notes from './notes'; export default () => { const dataEl = document.querySelector('.js-notes-data'); @@ -10,5 +10,7 @@ export default () => { autocomplete, } = JSON.parse(dataEl.innerHTML); - window.notes = new Notes(notesUrl, notesIds, now, diffView, autocomplete); + // Create a singleton so that we don't need to assign + // into the window object, we can just access the current isntance with Notes.instance + Notes.initialize(notesUrl, notesIds, now, diffView, autocomplete); }; diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 91b5ef1c10a..411c820cc43 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -48,7 +48,7 @@ export default class Issue { }) .fail(() => new Flash(issueFailMessage)) .done((data) => { - const isClosedBadge = $('div.status-box-closed'); + const isClosedBadge = $('div.status-box-issue-closed'); const isOpenBadge = $('div.status-box-open'); const projectIssuesCounter = $('.issue_counter'); diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index fd1a50dd533..952f49d522e 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -9,7 +9,7 @@ import titleComponent from './title.vue'; import descriptionComponent from './description.vue'; import editedComponent from './edited.vue'; import formComponent from './form.vue'; -import RecaptchaDialogImplementor from '../../vue_shared/mixins/recaptcha_dialog_implementor'; +import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor'; export default { props: { @@ -32,7 +32,7 @@ export default { showInlineEditButton: { type: Boolean, required: false, - default: false, + default: true, }, showDeleteButton: { type: Boolean, @@ -152,7 +152,7 @@ export default { }, mixins: [ - RecaptchaDialogImplementor, + recaptchaModalImplementor, ], methods: { @@ -197,7 +197,7 @@ export default { }); }, - closeRecaptchaDialog() { + closeRecaptchaModal() { this.store.setFormState({ updateLoading: false, }); @@ -273,10 +273,10 @@ export default { :enable-autocomplete="enableAutocomplete" /> - <recaptcha-dialog + <recaptcha-modal v-show="showRecaptcha" :html="recaptchaHTML" - @close="closeRecaptchaDialog" + @close="closeRecaptchaModal" /> </div> <div v-else> diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index feb73481422..c3f2bf130bb 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -1,12 +1,12 @@ <script> import animateMixin from '../mixins/animate'; import TaskList from '../../task_list'; - import RecaptchaDialogImplementor from '../../vue_shared/mixins/recaptcha_dialog_implementor'; + import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor'; export default { mixins: [ animateMixin, - RecaptchaDialogImplementor, + recaptchaModalImplementor, ], props: { @@ -126,7 +126,7 @@ > </textarea> - <recaptcha-dialog + <recaptcha-modal v-show="showRecaptcha" :html="recaptchaHTML" @close="closeRecaptcha" diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue index 52fe4ecd08b..4e577546551 100644 --- a/app/assets/javascripts/issue_show/components/fields/description.vue +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -53,7 +53,7 @@ <textarea id="issue-description" class="note-textarea js-gfm-input js-autosize markdown-area" - data-supports-quick-actionss="false" + data-supports-quick-actions="false" aria-label="Description" v-model="formState.description" ref="textarea" diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue index a363d06d950..b7e6eadd440 100644 --- a/app/assets/javascripts/issue_show/components/title.vue +++ b/app/assets/javascripts/issue_show/components/title.vue @@ -79,7 +79,7 @@ v-tooltip v-if="showInlineEditButton && canUpdate" type="button" - class="btn btn-default btn-edit btn-svg" + class="btn btn-default btn-edit btn-svg js-issuable-edit" v-html="pencilIcon" title="Edit title and description" data-placement="bottom" diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index 7b762496ba5..75dfdedcf1b 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -1,5 +1,4 @@ import Vue from 'vue'; -import eventHub from './event_hub'; import issuableApp from './components/app.vue'; import '../vue_shared/vue_resource_interceptor'; @@ -7,12 +6,6 @@ document.addEventListener('DOMContentLoaded', () => { const initialDataEl = document.getElementById('js-issuable-app-initial-data'); const props = JSON.parse(initialDataEl.innerHTML.replace(/"/g, '"')); - $('.js-issuable-edit').on('click', (e) => { - e.preventDefault(); - - eventHub.$emit('open.form'); - }); - return new Vue({ el: document.getElementById('js-issuable-app'), components: { diff --git a/app/assets/javascripts/job.js b/app/assets/javascripts/job.js index 06b0e02a870..198a7823381 100644 --- a/app/assets/javascripts/job.js +++ b/app/assets/javascripts/job.js @@ -3,6 +3,7 @@ import { visitUrl } from './lib/utils/url_utility'; import bp from './breakpoints'; import { bytesToKiB } from './lib/utils/number_utils'; import { setCiStatusFavicon } from './lib/utils/common_utils'; +import { timeFor } from './lib/utils/datetime_utility'; export default class Job { constructor(options) { @@ -261,7 +262,7 @@ export default class Job { if ($date.length) { const date = $date.text(); return $date.text( - gl.utils.timeFor(new Date(date.replace(/([0-9]+)-([0-9]+)-([0-9]+)/g, '$1/$2/$3')), ' '), + timeFor(new Date(date.replace(/([0-9]+)-([0-9]+)-([0-9]+)/g, '$1/$2/$3'))), ); } } diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js index a6f82b247e2..ab3cc29146a 100644 --- a/app/assets/javascripts/layout_nav.js +++ b/app/assets/javascripts/layout_nav.js @@ -1,59 +1,51 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, no-unused-vars, one-var, one-var-declaration-per-line, vars-on-top, max-len */ -import _ from 'underscore'; -import Cookies from 'js-cookie'; import ContextualSidebar from './contextual_sidebar'; import initFlyOutNav from './fly_out_nav'; -(function() { - var hideEndFade; +function hideEndFade($scrollingTabs) { + $scrollingTabs.each(function scrollTabsLoop() { + const $this = $(this); + $this.siblings('.fade-right').toggleClass('scrolling', $this.width() < $this.prop('scrollWidth')); + }); +} - hideEndFade = function($scrollingTabs) { - return $scrollingTabs.each(function() { - var $this; - $this = $(this); - return $this.siblings('.fade-right').toggleClass('scrolling', $this.width() < $this.prop('scrollWidth')); - }); - }; +export default function initLayoutNav() { + const contextualSidebar = new ContextualSidebar(); + contextualSidebar.bindEvents(); + + initFlyOutNav(); $(document).on('init.scrolling-tabs', () => { const $scrollingTabs = $('.scrolling-tabs').not('.is-initialized'); $scrollingTabs.addClass('is-initialized'); - hideEndFade($scrollingTabs); - $(window).off('resize.nav').on('resize.nav', function() { - return hideEndFade($scrollingTabs); - }); - $scrollingTabs.off('scroll').on('scroll', function(event) { - var $this, currentPosition, maxPosition; - $this = $(this); - currentPosition = $this.scrollLeft(); - maxPosition = $this.prop('scrollWidth') - $this.outerWidth(); + $(window).on('resize.nav', () => { + hideEndFade($scrollingTabs); + }).trigger('resize.nav'); + + $scrollingTabs.on('scroll', function tabsScrollEvent() { + const $this = $(this); + const currentPosition = $this.scrollLeft(); + const maxPosition = $this.prop('scrollWidth') - $this.outerWidth(); + $this.siblings('.fade-left').toggleClass('scrolling', currentPosition > 0); - return $this.siblings('.fade-right').toggleClass('scrolling', currentPosition < maxPosition - 1); + $this.siblings('.fade-right').toggleClass('scrolling', currentPosition < maxPosition - 1); }); - $scrollingTabs.each(function () { - var $this = $(this); - var scrollingTabWidth = $this.width(); - var $active = $this.find('.active'); - var activeWidth = $active.width(); + $scrollingTabs.each(function scrollTabsEachLoop() { + const $this = $(this); + const scrollingTabWidth = $this.width(); + const $active = $this.find('.active'); + const activeWidth = $active.width(); if ($active.length) { - var offset = $active.offset().left + activeWidth; + const offset = $active.offset().left + activeWidth; if (offset > scrollingTabWidth - 30) { - var scrollLeft = scrollingTabWidth / 2; - scrollLeft = (offset - scrollLeft) - (activeWidth / 2); + const scrollLeft = (offset - (scrollingTabWidth / 2)) - (activeWidth / 2); + $this.scrollLeft(scrollLeft); } } }); - }); - - $(() => { - const contextualSidebar = new ContextualSidebar(); - contextualSidebar.bindEvents(); - - initFlyOutNav(); - }); -}).call(window); + }).trigger('init.scrolling-tabs'); +} diff --git a/app/assets/javascripts/lib/utils/cache.js b/app/assets/javascripts/lib/utils/cache.js index 3141f1eeafc..596bd1e388a 100644 --- a/app/assets/javascripts/lib/utils/cache.js +++ b/app/assets/javascripts/lib/utils/cache.js @@ -1,4 +1,4 @@ -class Cache { +export default class Cache { constructor() { this.internalStorage = { }; } @@ -15,5 +15,3 @@ class Cache { delete this.internalStorage[key]; } } - -export default Cache; diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index 7a72509d234..9a61003ef30 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -1,3 +1,2 @@ -/* eslint-disable import/prefer-default-export */ export const BYTES_IN_KIB = 1024; export const HIDDEN_CLASS = 'hidden'; diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index d0578b230b1..1fa6715180e 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -1,132 +1,141 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, comma-dangle, no-unused-expressions, prefer-template, max-len */ - import timeago from 'timeago.js'; import dateFormat from 'vendor/date.format'; import { pluralize } from './text_utility'; - import { - lang, + languageCode, s__, } from '../../locale'; window.timeago = timeago; window.dateFormat = dateFormat; -(function() { - (function(w) { - var base; - var timeagoInstance; +/** + * Given a date object returns the day of the week in English + * @param {date} date + * @returns {String} + */ +export const getDayName = date => ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][date.getDay()]; - if (w.gl == null) { - w.gl = {}; - } - if ((base = w.gl).utils == null) { - base.utils = {}; - } - w.gl.utils.days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; +/** + * @example + * dateFormat('2017-12-05','mmm d, yyyy h:MMtt Z' ) -> "Dec 5, 2017 12:00am GMT+0000" + * @param {date} datetime + * @returns {String} + */ +export const formatDate = datetime => dateFormat(datetime, 'mmm d, yyyy h:MMtt Z'); - w.gl.utils.formatDate = function(datetime) { - return dateFormat(datetime, 'mmm d, yyyy h:MMtt Z'); - }; +/** + * Timeago uses underscores instead of dashes to separate language from country code. + * + * see https://github.com/hustcc/timeago.js/tree/v3.0.0/locales + */ +const timeagoLanguageCode = languageCode().replace(/-/g, '_'); - w.gl.utils.getDayName = function(date) { - return this.days[date.getDay()]; +let timeagoInstance; + +/** + * Sets a timeago Instance + */ +export function getTimeago() { + if (!timeagoInstance) { + const localeRemaining = function getLocaleRemaining(number, index) { + return [ + [s__('Timeago|less than a minute ago'), s__('Timeago|in a while')], + [s__('Timeago|less than a minute ago'), s__('Timeago|%s seconds remaining')], + [s__('Timeago|about a minute ago'), s__('Timeago|1 minute remaining')], + [s__('Timeago|%s minutes ago'), s__('Timeago|%s minutes remaining')], + [s__('Timeago|about an hour ago'), s__('Timeago|1 hour remaining')], + [s__('Timeago|about %s hours ago'), s__('Timeago|%s hours remaining')], + [s__('Timeago|a day ago'), s__('Timeago|1 day remaining')], + [s__('Timeago|%s days ago'), s__('Timeago|%s days remaining')], + [s__('Timeago|a week ago'), s__('Timeago|1 week remaining')], + [s__('Timeago|%s weeks ago'), s__('Timeago|%s weeks remaining')], + [s__('Timeago|a month ago'), s__('Timeago|1 month remaining')], + [s__('Timeago|%s months ago'), s__('Timeago|%s months remaining')], + [s__('Timeago|a year ago'), s__('Timeago|1 year remaining')], + [s__('Timeago|%s years ago'), s__('Timeago|%s years remaining')], + ][index]; + }; + const locale = function getLocale(number, index) { + return [ + [s__('Timeago|less than a minute ago'), s__('Timeago|in a while')], + [s__('Timeago|less than a minute ago'), s__('Timeago|in %s seconds')], + [s__('Timeago|about a minute ago'), s__('Timeago|in 1 minute')], + [s__('Timeago|%s minutes ago'), s__('Timeago|in %s minutes')], + [s__('Timeago|about an hour ago'), s__('Timeago|in 1 hour')], + [s__('Timeago|about %s hours ago'), s__('Timeago|in %s hours')], + [s__('Timeago|a day ago'), s__('Timeago|in 1 day')], + [s__('Timeago|%s days ago'), s__('Timeago|in %s days')], + [s__('Timeago|a week ago'), s__('Timeago|in 1 week')], + [s__('Timeago|%s weeks ago'), s__('Timeago|in %s weeks')], + [s__('Timeago|a month ago'), s__('Timeago|in 1 month')], + [s__('Timeago|%s months ago'), s__('Timeago|in %s months')], + [s__('Timeago|a year ago'), s__('Timeago|in 1 year')], + [s__('Timeago|%s years ago'), s__('Timeago|in %s years')], + ][index]; }; - w.gl.utils.localTimeAgo = function($timeagoEls, setTimeago = true) { - $timeagoEls.each((i, el) => { - if (setTimeago) { - // Recreate with custom template - $(el).tooltip({ - template: '<div class="tooltip local-timeago" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>' - }); - } + timeago.register(timeagoLanguageCode, locale); + timeago.register(`${timeagoLanguageCode}-remaining`, localeRemaining); + timeagoInstance = timeago(); + } - el.classList.add('js-timeago-render'); - }); + return timeagoInstance; +} - gl.utils.renderTimeago($timeagoEls); - }; +/** + * For the given element, renders a timeago instance. + * @param {jQuery} $els + */ +export const renderTimeago = ($els) => { + const timeagoEls = $els || document.querySelectorAll('.js-timeago-render'); - w.gl.utils.getTimeago = function() { - var locale; - - if (!timeagoInstance) { - const localeRemaining = function(number, index) { - return [ - [s__('Timeago|less than a minute ago'), s__('Timeago|in a while')], - [s__('Timeago|less than a minute ago'), s__('Timeago|%s seconds remaining')], - [s__('Timeago|about a minute ago'), s__('Timeago|1 minute remaining')], - [s__('Timeago|%s minutes ago'), s__('Timeago|%s minutes remaining')], - [s__('Timeago|about an hour ago'), s__('Timeago|1 hour remaining')], - [s__('Timeago|about %s hours ago'), s__('Timeago|%s hours remaining')], - [s__('Timeago|a day ago'), s__('Timeago|1 day remaining')], - [s__('Timeago|%s days ago'), s__('Timeago|%s days remaining')], - [s__('Timeago|a week ago'), s__('Timeago|1 week remaining')], - [s__('Timeago|%s weeks ago'), s__('Timeago|%s weeks remaining')], - [s__('Timeago|a month ago'), s__('Timeago|1 month remaining')], - [s__('Timeago|%s months ago'), s__('Timeago|%s months remaining')], - [s__('Timeago|a year ago'), s__('Timeago|1 year remaining')], - [s__('Timeago|%s years ago'), s__('Timeago|%s years remaining')] - ][index]; - }; - locale = function(number, index) { - return [ - [s__('Timeago|less than a minute ago'), s__('Timeago|in a while')], - [s__('Timeago|less than a minute ago'), s__('Timeago|in %s seconds')], - [s__('Timeago|about a minute ago'), s__('Timeago|in 1 minute')], - [s__('Timeago|%s minutes ago'), s__('Timeago|in %s minutes')], - [s__('Timeago|about an hour ago'), s__('Timeago|in 1 hour')], - [s__('Timeago|about %s hours ago'), s__('Timeago|in %s hours')], - [s__('Timeago|a day ago'), s__('Timeago|in 1 day')], - [s__('Timeago|%s days ago'), s__('Timeago|in %s days')], - [s__('Timeago|a week ago'), s__('Timeago|in 1 week')], - [s__('Timeago|%s weeks ago'), s__('Timeago|in %s weeks')], - [s__('Timeago|a month ago'), s__('Timeago|in 1 month')], - [s__('Timeago|%s months ago'), s__('Timeago|in %s months')], - [s__('Timeago|a year ago'), s__('Timeago|in 1 year')], - [s__('Timeago|%s years ago'), s__('Timeago|in %s years')] - ][index]; - }; - - timeago.register(lang, locale); - timeago.register(`${lang}-remaining`, localeRemaining); - timeagoInstance = timeago(); - } - - return timeagoInstance; - }; + // timeago.js sets timeouts internally for each timeago value to be updated in real time + getTimeago().render(timeagoEls, timeagoLanguageCode); +}; - w.gl.utils.timeFor = function(time, suffix, expiredLabel) { - var timefor; - if (!time) { - return ''; - } - if (new Date(time) < new Date()) { - expiredLabel || (expiredLabel = s__('Timeago|Past due')); - timefor = expiredLabel; - } else { - timefor = gl.utils.getTimeago().format(time, `${lang}-remaining`).trim(); - } - return timefor; - }; +/** + * For the given elements, sets a tooltip with a formatted date. + * @param {jQuery} + * @param {Boolean} setTimeago + */ +export const localTimeAgo = ($timeagoEls, setTimeago = true) => { + $timeagoEls.each((i, el) => { + if (setTimeago) { + // Recreate with custom template + $(el).tooltip({ + template: '<div class="tooltip local-timeago" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>', + }); + } - w.gl.utils.renderTimeago = function($els) { - const timeagoEls = $els || document.querySelectorAll('.js-timeago-render'); + el.classList.add('js-timeago-render'); + }); - // timeago.js sets timeouts internally for each timeago value to be updated in real time - gl.utils.getTimeago().render(timeagoEls, lang); - }; + renderTimeago($timeagoEls); +}; - w.gl.utils.getDayDifference = function(a, b) { - var millisecondsPerDay = 1000 * 60 * 60 * 24; - var date1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()); - var date2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()); +/** + * Returns remaining or passed time over the given time. + * @param {*} time + * @param {*} expiredLabel + */ +export const timeFor = (time, expiredLabel) => { + if (!time) { + return ''; + } + if (new Date(time) < new Date()) { + return expiredLabel || s__('Timeago|Past due'); + } + return getTimeago().format(time, `${timeagoLanguageCode}-remaining`).trim(); +}; - return Math.floor((date2 - date1) / millisecondsPerDay); - }; - })(window); -}).call(window); +export const getDayDifference = (a, b) => { + const millisecondsPerDay = 1000 * 60 * 60 * 24; + const date1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()); + const date2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()); + + return Math.floor((date2 - date1) / millisecondsPerDay); +}; /** * Port of ruby helper time_interval_in_words. @@ -162,3 +171,10 @@ export function dateInWords(date, abbreviated = false) { return `${monthName} ${date.getDate()}, ${year}`; } + +window.gl = window.gl || {}; +window.gl.utils = { + ...(window.gl.utils || {}), + getTimeago, + localTimeAgo, +}; diff --git a/app/assets/javascripts/lib/utils/tick_formats.js b/app/assets/javascripts/lib/utils/tick_formats.js new file mode 100644 index 00000000000..0c10a85e336 --- /dev/null +++ b/app/assets/javascripts/lib/utils/tick_formats.js @@ -0,0 +1,39 @@ +import { createDateTimeFormat } from '../../locale'; + +let dateTimeFormats; + +export const initDateFormats = () => { + const dayFormat = createDateTimeFormat({ month: 'short', day: 'numeric' }); + const monthFormat = createDateTimeFormat({ month: 'long' }); + const yearFormat = createDateTimeFormat({ year: 'numeric' }); + + dateTimeFormats = { + dayFormat, + monthFormat, + yearFormat, + }; +}; + +initDateFormats(); + +/** + Formats a localized date in way that it can be used for d3.js axis.tickFormat(). + + That is, it displays + - 4-digit for first of January + - full month name for first of every month + - day and abbreviated month otherwise + + see also https://github.com/d3/d3-3.x-api-reference/blob/master/SVG-Axes.md#tickFormat + */ +export const dateTickFormat = (date) => { + if (date.getDate() !== 1) { + return dateTimeFormats.dayFormat.format(date); + } + + if (date.getMonth() > 0) { + return dateTimeFormats.monthFormat.format(date); + } + + return dateTimeFormats.yearFormat.format(date); +}; diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js index a75d1a4b8d0..fbd381d8ff7 100644 --- a/app/assets/javascripts/line_highlighter.js +++ b/app/assets/javascripts/line_highlighter.js @@ -175,4 +175,4 @@ LineHighlighter.prototype.__setLocationHash__ = function(value) { }, document.title, value); }; -window.LineHighlighter = LineHighlighter; +export default LineHighlighter; diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js index 1003b9ba0af..2f4328b56e1 100644 --- a/app/assets/javascripts/locale/index.js +++ b/app/assets/javascripts/locale/index.js @@ -1,8 +1,7 @@ import Jed from 'jed'; import sprintf from './sprintf'; -const langAttribute = document.querySelector('html').getAttribute('lang'); -const lang = (langAttribute || 'en').replace(/-/g, '_'); +const languageCode = () => document.querySelector('html').getAttribute('lang') || 'en'; const locale = new Jed(window.translations || {}); delete window.translations; @@ -47,9 +46,19 @@ const pgettext = (keyOrContext, key) => { return translated[translated.length - 1]; }; -export { lang }; +/** + Creates an instance of Intl.DateTimeFormat for the current locale. + + @param formatOptions for available options, please see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat + @returns {Intl.DateTimeFormat} +*/ +const createDateTimeFormat = + formatOptions => Intl.DateTimeFormat(languageCode(), formatOptions); + +export { languageCode }; export { gettext as __ }; export { ngettext as n__ }; export { pgettext as s__ }; export { sprintf }; +export { createDateTimeFormat }; export default locale; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 2db865f8abf..59bfa482bb0 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -1,6 +1,5 @@ /* eslint-disable func-names, space-before-function-paren, no-var, quotes, consistent-return, prefer-arrow-callback, comma-dangle, object-shorthand, no-new, max-len, no-multi-spaces, import/newline-after-import, import/first */ /* global ConfirmDangerModal */ -/* global Aside */ import jQuery from 'jquery'; import _ from 'underscore'; @@ -28,42 +27,28 @@ import './commit/image_file'; // lib/utils import { handleLocationHash } from './lib/utils/common_utils'; -import './lib/utils/datetime_utility'; +import { localTimeAgo, renderTimeago } from './lib/utils/datetime_utility'; import { getLocationHash, visitUrl } from './lib/utils/url_utility'; // behaviors import './behaviors/'; // everything else -import './activities'; -import './admin'; -import './aside'; import loadAwardsHandler from './awards_handler'; import bp from './breakpoints'; import './confirm_danger_modal'; import Flash, { removeFlashClickListener } from './flash'; import './gl_dropdown'; -import './gl_field_error'; -import './gl_field_errors'; -import './gl_form'; import initTodoToggle from './header'; import initImporterStatus from './importer_status'; -import './layout_nav'; +import initLayoutNav from './layout_nav'; import LazyLoader from './lazy_loader'; import './line_highlighter'; import initLogoAnimation from './logo'; -import './merge_request'; -import './merge_request_tabs'; import './milestone_select'; -import './notes'; -import './notifications_dropdown'; -import './notifications_form'; -import './pager'; import './preview_markdown'; -import './project_import'; import './projects_dropdown'; import './render_gfm'; -import './right_sidebar'; import initBreadcrumbs from './breadcrumb'; import './dispatcher'; @@ -104,6 +89,7 @@ $(function () { var fitSidebarForSize; initBreadcrumbs(); + initLayoutNav(); initImporterStatus(); initTodoToggle(); initLogoAnimation(); @@ -186,7 +172,7 @@ $(function () { return $(this).parents('form').submit(); // Form submitter }); - gl.utils.localTimeAgo($('abbr.timeago, .js-timeago'), true); + localTimeAgo($('abbr.timeago, .js-timeago'), true); // Disable form buttons while a form is submitting $body.on('ajax:complete, ajax:beforeSend, submit', 'form', function (e) { var buttons; @@ -273,11 +259,8 @@ $(function () { return fitSidebarForSize(); }); loadAwardsHandler(); - new Aside(); - gl.utils.renderTimeago(); - - $(document).trigger('init.scrolling-tabs'); + renderTimeago(); $('form.filter-form').on('submit', function (event) { const link = document.createElement('a'); diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index a9c08df4f93..cb3cdea8111 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -1,148 +1,143 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, quotes, no-underscore-dangle, one-var, one-var-declaration-per-line, consistent-return, dot-notation, quote-props, comma-dangle, object-shorthand, max-len, prefer-arrow-callback */ -/* global MergeRequestTabs */ import 'vendor/jquery.waitforimages'; import TaskList from './task_list'; -import './merge_request_tabs'; +import MergeRequestTabs from './merge_request_tabs'; import IssuablesHelper from './helpers/issuables_helper'; import { addDelimiter } from './lib/utils/text_utility'; -(function() { - this.MergeRequest = (function() { - function MergeRequest(opts) { - // Initialize MergeRequest behavior - // - // Options: - // action - String, current controller action - // - this.opts = opts != null ? opts : {}; - this.submitNoteForm = this.submitNoteForm.bind(this); - this.$el = $('.merge-request'); - this.$('.show-all-commits').on('click', (function(_this) { - return function() { - return _this.showAllCommits(); - }; - })(this)); - - this.initTabs(); - this.initMRBtnListeners(); - this.initCommitMessageListeners(); - this.closeReopenReportToggle = IssuablesHelper.initCloseReopenReport(); - - if ($("a.btn-close").length) { - this.taskList = new TaskList({ - dataType: 'merge_request', - fieldName: 'description', - selector: '.detail-page-description', - onSuccess: (result) => { - document.querySelector('#task_status').innerText = result.task_status; - document.querySelector('#task_status_short').innerText = result.task_status_short; - } - }); - } - } - - // Local jQuery finder - MergeRequest.prototype.$ = function(selector) { - return this.$el.find(selector); +function MergeRequest(opts) { + // Initialize MergeRequest behavior + // + // Options: + // action - String, current controller action + // + this.opts = opts != null ? opts : {}; + this.submitNoteForm = this.submitNoteForm.bind(this); + this.$el = $('.merge-request'); + this.$('.show-all-commits').on('click', (function(_this) { + return function() { + return _this.showAllCommits(); }; - - MergeRequest.prototype.initTabs = function() { - if (window.mrTabs) { - window.mrTabs.unbindEvents(); + })(this)); + + this.initTabs(); + this.initMRBtnListeners(); + this.initCommitMessageListeners(); + this.closeReopenReportToggle = IssuablesHelper.initCloseReopenReport(); + + if ($("a.btn-close").length) { + this.taskList = new TaskList({ + dataType: 'merge_request', + fieldName: 'description', + selector: '.detail-page-description', + onSuccess: (result) => { + document.querySelector('#task_status').innerText = result.task_status; + document.querySelector('#task_status_short').innerText = result.task_status_short; } - window.mrTabs = new gl.MergeRequestTabs(this.opts); - }; - - MergeRequest.prototype.showAllCommits = function() { - this.$('.first-commits').remove(); - return this.$('.all-commits').removeClass('hide'); - }; - - MergeRequest.prototype.initMRBtnListeners = function() { - var _this; - _this = this; - return $('a.btn-close, a.btn-reopen').on('click', function(e) { - var $this, shouldSubmit; - $this = $(this); - shouldSubmit = $this.hasClass('btn-comment'); - if (shouldSubmit && $this.data('submitted')) { - return; - } - - if (this.closeReopenReportToggle) this.closeReopenReportToggle.setDisable(); - - if (shouldSubmit) { - if ($this.hasClass('btn-comment-and-close') || $this.hasClass('btn-comment-and-reopen')) { - e.preventDefault(); - e.stopImmediatePropagation(); - - _this.submitNoteForm($this.closest('form'), $this); - } - } - }); - }; - - MergeRequest.prototype.submitNoteForm = function(form, $button) { - var noteText; - noteText = form.find("textarea.js-note-text").val(); - if (noteText.trim().length > 0) { - form.submit(); - $button.data('submitted', true); - return $button.trigger('click'); - } - }; - - MergeRequest.prototype.initCommitMessageListeners = function() { - $(document).on('click', 'a.js-with-description-link', function(e) { - var textarea = $('textarea.js-commit-message'); - e.preventDefault(); + }); + } +} + +// Local jQuery finder +MergeRequest.prototype.$ = function(selector) { + return this.$el.find(selector); +}; + +MergeRequest.prototype.initTabs = function() { + if (window.mrTabs) { + window.mrTabs.unbindEvents(); + } + window.mrTabs = new MergeRequestTabs(this.opts); +}; + +MergeRequest.prototype.showAllCommits = function() { + this.$('.first-commits').remove(); + return this.$('.all-commits').removeClass('hide'); +}; + +MergeRequest.prototype.initMRBtnListeners = function() { + var _this; + _this = this; + return $('a.btn-close, a.btn-reopen').on('click', function(e) { + var $this, shouldSubmit; + $this = $(this); + shouldSubmit = $this.hasClass('btn-comment'); + if (shouldSubmit && $this.data('submitted')) { + return; + } - textarea.val(textarea.data('messageWithDescription')); - $('.js-with-description-hint').hide(); - $('.js-without-description-hint').show(); - }); + if (this.closeReopenReportToggle) this.closeReopenReportToggle.setDisable(); - $(document).on('click', 'a.js-without-description-link', function(e) { - var textarea = $('textarea.js-commit-message'); + if (shouldSubmit) { + if ($this.hasClass('btn-comment-and-close') || $this.hasClass('btn-comment-and-reopen')) { e.preventDefault(); + e.stopImmediatePropagation(); - textarea.val(textarea.data('messageWithoutDescription')); - $('.js-with-description-hint').show(); - $('.js-without-description-hint').hide(); - }); - }; - - MergeRequest.prototype.updateStatusText = function(classToRemove, classToAdd, newStatusText) { - $('.detail-page-header .status-box') - .removeClass(classToRemove) - .addClass(classToAdd) - .find('span') - .text(newStatusText); - }; - - MergeRequest.prototype.decreaseCounter = function(by = 1) { - const $el = $('.nav-links .js-merge-counter'); - const count = Math.max((parseInt($el.text().replace(/[^\d]/, ''), 10) - by), 0); - - $el.text(addDelimiter(count)); - }; - - MergeRequest.prototype.hideCloseButton = function() { - const el = document.querySelector('.merge-request .js-issuable-actions'); - const closeDropdownItem = el.querySelector('li.close-item'); - if (closeDropdownItem) { - closeDropdownItem.classList.add('hidden'); - // Selects the next dropdown item - el.querySelector('li.report-item').click(); - } else { - // No dropdown just hide the Close button - el.querySelector('.btn-close').classList.add('hidden'); + _this.submitNoteForm($this.closest('form'), $this); } - // Dropdown for mobile screen - el.querySelector('li.js-close-item').classList.add('hidden'); - }; - - return MergeRequest; - })(); -}).call(window); + } + }); +}; + +MergeRequest.prototype.submitNoteForm = function(form, $button) { + var noteText; + noteText = form.find("textarea.js-note-text").val(); + if (noteText.trim().length > 0) { + form.submit(); + $button.data('submitted', true); + return $button.trigger('click'); + } +}; + +MergeRequest.prototype.initCommitMessageListeners = function() { + $(document).on('click', 'a.js-with-description-link', function(e) { + var textarea = $('textarea.js-commit-message'); + e.preventDefault(); + + textarea.val(textarea.data('messageWithDescription')); + $('.js-with-description-hint').hide(); + $('.js-without-description-hint').show(); + }); + + $(document).on('click', 'a.js-without-description-link', function(e) { + var textarea = $('textarea.js-commit-message'); + e.preventDefault(); + + textarea.val(textarea.data('messageWithoutDescription')); + $('.js-with-description-hint').show(); + $('.js-without-description-hint').hide(); + }); +}; + +MergeRequest.prototype.updateStatusText = function(classToRemove, classToAdd, newStatusText) { + $('.detail-page-header .status-box') + .removeClass(classToRemove) + .addClass(classToAdd) + .find('span') + .text(newStatusText); +}; + +MergeRequest.prototype.decreaseCounter = function(by = 1) { + const $el = $('.nav-links .js-merge-counter'); + const count = Math.max((parseInt($el.text().replace(/[^\d]/, ''), 10) - by), 0); + + $el.text(addDelimiter(count)); +}; + +MergeRequest.prototype.hideCloseButton = function() { + const el = document.querySelector('.merge-request .js-issuable-actions'); + const closeDropdownItem = el.querySelector('li.close-item'); + if (closeDropdownItem) { + closeDropdownItem.classList.add('hidden'); + // Selects the next dropdown item + el.querySelector('li.report-item').click(); + } else { + // No dropdown just hide the Close button + el.querySelector('.btn-close').classList.add('hidden'); + } + // Dropdown for mobile screen + el.querySelector('li.js-close-item').classList.add('hidden'); +}; + +export default MergeRequest; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index a630daa6bdc..acfc62fe5cb 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -1,5 +1,4 @@ /* eslint-disable no-new, class-methods-use-this */ -/* global notes */ import Cookies from 'js-cookie'; import Flash from './flash'; @@ -14,7 +13,9 @@ import { import { getLocationHash } from './lib/utils/url_utility'; import initDiscussionTab from './image_diff/init_discussion_tab'; import Diff from './diff'; +import { localTimeAgo } from './lib/utils/datetime_utility'; import syntaxHighlight from './syntax_highlight'; +import Notes from './notes'; /* eslint-disable max-len */ // MergeRequestTabs @@ -62,387 +63,382 @@ import syntaxHighlight from './syntax_highlight'; // /* eslint-enable max-len */ -(() => { - // Store the `location` object, allowing for easier stubbing in tests - let location = window.location; +// Store the `location` object, allowing for easier stubbing in tests +let location = window.location; - class MergeRequestTabs { +export default class MergeRequestTabs { - constructor({ action, setUrl, stubLocation } = {}) { - const mergeRequestTabs = document.querySelector('.js-tabs-affix'); - const navbar = document.querySelector('.navbar-gitlab'); - const paddingTop = 16; + constructor({ action, setUrl, stubLocation } = {}) { + const mergeRequestTabs = document.querySelector('.js-tabs-affix'); + const navbar = document.querySelector('.navbar-gitlab'); + const paddingTop = 16; - this.diffsLoaded = false; - this.pipelinesLoaded = false; - this.commitsLoaded = false; - this.fixedLayoutPref = null; + this.diffsLoaded = false; + this.pipelinesLoaded = false; + this.commitsLoaded = false; + this.fixedLayoutPref = null; - this.setUrl = setUrl !== undefined ? setUrl : true; - this.setCurrentAction = this.setCurrentAction.bind(this); - this.tabShown = this.tabShown.bind(this); - this.showTab = this.showTab.bind(this); - this.stickyTop = navbar ? navbar.offsetHeight - paddingTop : 0; + this.setUrl = setUrl !== undefined ? setUrl : true; + this.setCurrentAction = this.setCurrentAction.bind(this); + this.tabShown = this.tabShown.bind(this); + this.showTab = this.showTab.bind(this); + this.stickyTop = navbar ? navbar.offsetHeight - paddingTop : 0; - if (mergeRequestTabs) { - this.stickyTop += mergeRequestTabs.offsetHeight; - } - - if (stubLocation) { - location = stubLocation; - } + if (mergeRequestTabs) { + this.stickyTop += mergeRequestTabs.offsetHeight; + } - this.bindEvents(); - this.activateTab(action); - this.initAffix(); + if (stubLocation) { + location = stubLocation; } - bindEvents() { - $(document) - .on('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown) - .on('click', '.js-show-tab', this.showTab); + this.bindEvents(); + this.activateTab(action); + this.initAffix(); + } - $('.merge-request-tabs a[data-toggle="tab"]') - .on('click', this.clickTab); - } + bindEvents() { + $(document) + .on('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown) + .on('click', '.js-show-tab', this.showTab); - // Used in tests - unbindEvents() { - $(document) - .off('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown) - .off('click', '.js-show-tab', this.showTab); + $('.merge-request-tabs a[data-toggle="tab"]') + .on('click', this.clickTab); + } - $('.merge-request-tabs a[data-toggle="tab"]') - .off('click', this.clickTab); - } + // Used in tests + unbindEvents() { + $(document) + .off('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown) + .off('click', '.js-show-tab', this.showTab); - destroyPipelinesView() { - if (this.commitPipelinesTable) { - this.commitPipelinesTable.$destroy(); - this.commitPipelinesTable = null; + $('.merge-request-tabs a[data-toggle="tab"]') + .off('click', this.clickTab); + } - document.querySelector('#commit-pipeline-table-view').innerHTML = ''; - } + destroyPipelinesView() { + if (this.commitPipelinesTable) { + this.commitPipelinesTable.$destroy(); + this.commitPipelinesTable = null; + + document.querySelector('#commit-pipeline-table-view').innerHTML = ''; } + } - showTab(e) { + showTab(e) { + e.preventDefault(); + this.activateTab($(e.target).data('action')); + } + + clickTab(e) { + if (e.currentTarget && isMetaClick(e)) { + const targetLink = e.currentTarget.getAttribute('href'); + e.stopImmediatePropagation(); e.preventDefault(); - this.activateTab($(e.target).data('action')); + window.open(targetLink, '_blank'); } + } - clickTab(e) { - if (e.currentTarget && isMetaClick(e)) { - const targetLink = e.currentTarget.getAttribute('href'); - e.stopImmediatePropagation(); - e.preventDefault(); - window.open(targetLink, '_blank'); + tabShown(e) { + const $target = $(e.target); + const action = $target.data('action'); + + if (action === 'commits') { + this.loadCommits($target.attr('href')); + this.expandView(); + this.resetViewContainer(); + this.destroyPipelinesView(); + } else if (this.isDiffAction(action)) { + this.loadDiff($target.attr('href')); + if (bp.getBreakpointSize() !== 'lg') { + this.shrinkView(); } - } - - tabShown(e) { - const $target = $(e.target); - const action = $target.data('action'); - - if (action === 'commits') { - this.loadCommits($target.attr('href')); - this.expandView(); - this.resetViewContainer(); - this.destroyPipelinesView(); - } else if (this.isDiffAction(action)) { - this.loadDiff($target.attr('href')); - if (bp.getBreakpointSize() !== 'lg') { - this.shrinkView(); - } - if (this.diffViewType() === 'parallel') { - this.expandViewContainer(); - } - this.destroyPipelinesView(); - } else if (action === 'pipelines') { - this.resetViewContainer(); - this.mountPipelinesView(); - } else { - if (bp.getBreakpointSize() !== 'xs') { - this.expandView(); - } - this.resetViewContainer(); - this.destroyPipelinesView(); - - initDiscussionTab(); + if (this.diffViewType() === 'parallel') { + this.expandViewContainer(); } - if (this.setUrl) { - this.setCurrentAction(action); + this.destroyPipelinesView(); + } else if (action === 'pipelines') { + this.resetViewContainer(); + this.mountPipelinesView(); + } else { + if (bp.getBreakpointSize() !== 'xs') { + this.expandView(); } + this.resetViewContainer(); + this.destroyPipelinesView(); + + initDiscussionTab(); + } + if (this.setUrl) { + this.setCurrentAction(action); } + } - scrollToElement(container) { - if (location.hash) { - const offset = 0 - ( - $('.navbar-gitlab').outerHeight() + - $('.js-tabs-affix').outerHeight() - ); - const $el = $(`${container} ${location.hash}:not(.match)`); - if ($el.length) { - $.scrollTo($el[0], { offset }); - } + scrollToElement(container) { + if (location.hash) { + const offset = 0 - ( + $('.navbar-gitlab').outerHeight() + + $('.js-tabs-affix').outerHeight() + ); + const $el = $(`${container} ${location.hash}:not(.match)`); + if ($el.length) { + $.scrollTo($el[0], { offset }); } } + } - // Activate a tab based on the current action - activateTab(action) { - // important note: the .tab('show') method triggers 'shown.bs.tab' event itself - $(`.merge-request-tabs a[data-action='${action}']`).tab('show'); + // Activate a tab based on the current action + activateTab(action) { + // important note: the .tab('show') method triggers 'shown.bs.tab' event itself + $(`.merge-request-tabs a[data-action='${action}']`).tab('show'); + } + + // Replaces the current Merge Request-specific action in the URL with a new one + // + // If the action is "notes", the URL is reset to the standard + // `MergeRequests#show` route. + // + // Examples: + // + // location.pathname # => "/namespace/project/merge_requests/1" + // setCurrentAction('diffs') + // location.pathname # => "/namespace/project/merge_requests/1/diffs" + // + // location.pathname # => "/namespace/project/merge_requests/1/diffs" + // setCurrentAction('show') + // location.pathname # => "/namespace/project/merge_requests/1" + // + // location.pathname # => "/namespace/project/merge_requests/1/diffs" + // setCurrentAction('commits') + // location.pathname # => "/namespace/project/merge_requests/1/commits" + // + // Returns the new URL String + setCurrentAction(action) { + this.currentAction = action; + + // Remove a trailing '/commits' '/diffs' '/pipelines' + let newState = location.pathname.replace(/\/(commits|diffs|pipelines)(\.html)?\/?$/, ''); + + // Append the new action if we're on a tab other than 'notes' + if (this.currentAction !== 'show' && this.currentAction !== 'new') { + newState += `/${this.currentAction}`; } - // Replaces the current Merge Request-specific action in the URL with a new one - // - // If the action is "notes", the URL is reset to the standard - // `MergeRequests#show` route. - // - // Examples: - // - // location.pathname # => "/namespace/project/merge_requests/1" - // setCurrentAction('diffs') - // location.pathname # => "/namespace/project/merge_requests/1/diffs" - // - // location.pathname # => "/namespace/project/merge_requests/1/diffs" - // setCurrentAction('show') - // location.pathname # => "/namespace/project/merge_requests/1" - // - // location.pathname # => "/namespace/project/merge_requests/1/diffs" - // setCurrentAction('commits') - // location.pathname # => "/namespace/project/merge_requests/1/commits" - // - // Returns the new URL String - setCurrentAction(action) { - this.currentAction = action; + // Ensure parameters and hash come along for the ride + newState += location.search + location.hash; - // Remove a trailing '/commits' '/diffs' '/pipelines' - let newState = location.pathname.replace(/\/(commits|diffs|pipelines)(\.html)?\/?$/, ''); + // TODO: Consider refactoring in light of turbolinks removal. - // Append the new action if we're on a tab other than 'notes' - if (this.currentAction !== 'show' && this.currentAction !== 'new') { - newState += `/${this.currentAction}`; - } + // Replace the current history state with the new one without breaking + // Turbolinks' history. + // + // See https://github.com/rails/turbolinks/issues/363 + window.history.replaceState({ + url: newState, + }, document.title, newState); - // Ensure parameters and hash come along for the ride - newState += location.search + location.hash; + return newState; + } - // TODO: Consider refactoring in light of turbolinks removal. + loadCommits(source) { + if (this.commitsLoaded) { + return; + } + this.ajaxGet({ + url: `${source}.json`, + success: (data) => { + document.querySelector('div#commits').innerHTML = data.html; + localTimeAgo($('.js-timeago', 'div#commits')); + this.commitsLoaded = true; + this.scrollToElement('#commits'); + }, + }); + } - // Replace the current history state with the new one without breaking - // Turbolinks' history. - // - // See https://github.com/rails/turbolinks/issues/363 - window.history.replaceState({ - url: newState, - }, document.title, newState); + mountPipelinesView() { + const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view'); + const CommitPipelinesTable = gl.CommitPipelinesTable; + this.commitPipelinesTable = new CommitPipelinesTable({ + propsData: { + endpoint: pipelineTableViewEl.dataset.endpoint, + helpPagePath: pipelineTableViewEl.dataset.helpPagePath, + emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath, + errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath, + autoDevopsHelpPath: pipelineTableViewEl.dataset.helpAutoDevopsPath, + }, + }).$mount(); + + // $mount(el) replaces the el with the new rendered component. We need it in order to mount + // it everytime this tab is clicked - https://vuejs.org/v2/api/#vm-mount + pipelineTableViewEl.appendChild(this.commitPipelinesTable.$el); + } - return newState; + loadDiff(source) { + if (this.diffsLoaded) { + document.dispatchEvent(new CustomEvent('scroll')); + return; } - loadCommits(source) { - if (this.commitsLoaded) { - return; - } - this.ajaxGet({ - url: `${source}.json`, - success: (data) => { - document.querySelector('div#commits').innerHTML = data.html; - gl.utils.localTimeAgo($('.js-timeago', 'div#commits')); - this.commitsLoaded = true; - this.scrollToElement('#commits'); - }, - }); - } + // We extract pathname for the current Changes tab anchor href + // some pages like MergeRequestsController#new has query parameters on that anchor + const urlPathname = parseUrlPathname(source); - mountPipelinesView() { - const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view'); - const CommitPipelinesTable = gl.CommitPipelinesTable; - this.commitPipelinesTable = new CommitPipelinesTable({ - propsData: { - endpoint: pipelineTableViewEl.dataset.endpoint, - helpPagePath: pipelineTableViewEl.dataset.helpPagePath, - emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath, - errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath, - autoDevopsHelpPath: pipelineTableViewEl.dataset.helpAutoDevopsPath, - }, - }).$mount(); + this.ajaxGet({ + url: `${urlPathname}.json${location.search}`, + success: (data) => { + const $container = $('#diffs'); + $container.html(data.html); - // $mount(el) replaces the el with the new rendered component. We need it in order to mount - // it everytime this tab is clicked - https://vuejs.org/v2/api/#vm-mount - pipelineTableViewEl.appendChild(this.commitPipelinesTable.$el); - } + initChangesDropdown(this.stickyTop); - loadDiff(source) { - if (this.diffsLoaded) { - document.dispatchEvent(new CustomEvent('scroll')); - return; - } + if (typeof gl.diffNotesCompileComponents !== 'undefined') { + gl.diffNotesCompileComponents(); + } + + localTimeAgo($('.js-timeago', 'div#diffs')); + syntaxHighlight($('#diffs .js-syntax-highlight')); - // We extract pathname for the current Changes tab anchor href - // some pages like MergeRequestsController#new has query parameters on that anchor - const urlPathname = parseUrlPathname(source); - - this.ajaxGet({ - url: `${urlPathname}.json${location.search}`, - success: (data) => { - const $container = $('#diffs'); - $container.html(data.html); - - initChangesDropdown(this.stickyTop); - - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - gl.diffNotesCompileComponents(); - } - - gl.utils.localTimeAgo($('.js-timeago', 'div#diffs')); - syntaxHighlight($('#diffs .js-syntax-highlight')); - - if (this.diffViewType() === 'parallel' && this.isDiffAction(this.currentAction)) { - this.expandViewContainer(); - } - this.diffsLoaded = true; - - new Diff(); - this.scrollToElement('#diffs'); - - $('.diff-file').each((i, el) => { - new BlobForkSuggestion({ - openButtons: $(el).find('.js-edit-blob-link-fork-toggler'), - forkButtons: $(el).find('.js-fork-suggestion-button'), - cancelButtons: $(el).find('.js-cancel-fork-suggestion-button'), - suggestionSections: $(el).find('.js-file-fork-suggestion-section'), - actionTextPieces: $(el).find('.js-file-fork-suggestion-section-action'), - }) - .init(); + if (this.diffViewType() === 'parallel' && this.isDiffAction(this.currentAction)) { + this.expandViewContainer(); + } + this.diffsLoaded = true; + + new Diff(); + this.scrollToElement('#diffs'); + + $('.diff-file').each((i, el) => { + new BlobForkSuggestion({ + openButtons: $(el).find('.js-edit-blob-link-fork-toggler'), + forkButtons: $(el).find('.js-fork-suggestion-button'), + cancelButtons: $(el).find('.js-cancel-fork-suggestion-button'), + suggestionSections: $(el).find('.js-file-fork-suggestion-section'), + actionTextPieces: $(el).find('.js-file-fork-suggestion-section-action'), + }) + .init(); + }); + + // Scroll any linked note into view + // Similar to `toggler_behavior` in the discussion tab + const hash = getLocationHash(); + const anchor = hash && $container.find(`.note[id="${hash}"]`); + if (anchor && anchor.length > 0) { + const notesContent = anchor.closest('.notes_content'); + const lineType = notesContent.hasClass('new') ? 'new' : 'old'; + Notes.instance.toggleDiffNote({ + target: anchor, + lineType, + forceShow: true, }); + anchor[0].scrollIntoView(); + handleLocationHash(); + // We have multiple elements on the page with `#note_xxx` + // (discussion and diff tabs) and `:target` only applies to the first + anchor.addClass('target'); + } + }, + }); + } - // Scroll any linked note into view - // Similar to `toggler_behavior` in the discussion tab - const hash = getLocationHash(); - const anchor = hash && $container.find(`.note[id="${hash}"]`); - if (anchor && anchor.length > 0) { - const notesContent = anchor.closest('.notes_content'); - const lineType = notesContent.hasClass('new') ? 'new' : 'old'; - notes.toggleDiffNote({ - target: anchor, - lineType, - forceShow: true, - }); - anchor[0].scrollIntoView(); - handleLocationHash(); - // We have multiple elements on the page with `#note_xxx` - // (discussion and diff tabs) and `:target` only applies to the first - anchor.addClass('target'); - } - }, - }); - } + // Show or hide the loading spinner + // + // status - Boolean, true to show, false to hide + toggleLoading(status) { + $('.mr-loading-status .loading').toggle(status); + } - // Show or hide the loading spinner - // - // status - Boolean, true to show, false to hide - toggleLoading(status) { - $('.mr-loading-status .loading').toggle(status); - } + ajaxGet(options) { + const defaults = { + beforeSend: () => this.toggleLoading(true), + error: () => new Flash('An error occurred while fetching this tab.', 'alert'), + complete: () => this.toggleLoading(false), + dataType: 'json', + type: 'GET', + }; + $.ajax($.extend({}, defaults, options)); + } - ajaxGet(options) { - const defaults = { - beforeSend: () => this.toggleLoading(true), - error: () => new Flash('An error occurred while fetching this tab.', 'alert'), - complete: () => this.toggleLoading(false), - dataType: 'json', - type: 'GET', - }; - $.ajax($.extend({}, defaults, options)); - } + diffViewType() { + return $('.inline-parallel-buttons a.active').data('view-type'); + } - diffViewType() { - return $('.inline-parallel-buttons a.active').data('view-type'); - } + isDiffAction(action) { + return action === 'diffs' || action === 'new/diffs'; + } - isDiffAction(action) { - return action === 'diffs' || action === 'new/diffs'; + expandViewContainer() { + const $wrapper = $('.content-wrapper .container-fluid').not('.breadcrumbs'); + if (this.fixedLayoutPref === null) { + this.fixedLayoutPref = $wrapper.hasClass('container-limited'); } + $wrapper.removeClass('container-limited'); + } - expandViewContainer() { - const $wrapper = $('.content-wrapper .container-fluid').not('.breadcrumbs'); - if (this.fixedLayoutPref === null) { - this.fixedLayoutPref = $wrapper.hasClass('container-limited'); - } - $wrapper.removeClass('container-limited'); + resetViewContainer() { + if (this.fixedLayoutPref !== null) { + $('.content-wrapper .container-fluid') + .toggleClass('container-limited', this.fixedLayoutPref); } + } - resetViewContainer() { - if (this.fixedLayoutPref !== null) { - $('.content-wrapper .container-fluid') - .toggleClass('container-limited', this.fixedLayoutPref); - } - } + shrinkView() { + const $gutterIcon = $('.js-sidebar-toggle i:visible'); - shrinkView() { - const $gutterIcon = $('.js-sidebar-toggle i:visible'); + // Wait until listeners are set + setTimeout(() => { + // Only when sidebar is expanded + if ($gutterIcon.is('.fa-angle-double-right')) { + $gutterIcon.closest('a').trigger('click', [true]); + } + }, 0); + } - // Wait until listeners are set - setTimeout(() => { - // Only when sidebar is expanded - if ($gutterIcon.is('.fa-angle-double-right')) { - $gutterIcon.closest('a').trigger('click', [true]); - } - }, 0); + // Expand the issuable sidebar unless the user explicitly collapsed it + expandView() { + if (Cookies.get('collapsed_gutter') === 'true') { + return; } + const $gutterIcon = $('.js-sidebar-toggle i:visible'); - // Expand the issuable sidebar unless the user explicitly collapsed it - expandView() { - if (Cookies.get('collapsed_gutter') === 'true') { - return; + // Wait until listeners are set + setTimeout(() => { + // Only when sidebar is collapsed + if ($gutterIcon.is('.fa-angle-double-left')) { + $gutterIcon.closest('a').trigger('click', [true]); } - const $gutterIcon = $('.js-sidebar-toggle i:visible'); + }, 0); + } - // Wait until listeners are set - setTimeout(() => { - // Only when sidebar is collapsed - if ($gutterIcon.is('.fa-angle-double-left')) { - $gutterIcon.closest('a').trigger('click', [true]); - } - }, 0); - } + initAffix() { + const $tabs = $('.js-tabs-affix'); + const $fixedNav = $('.navbar-gitlab'); + + // Screen space on small screens is usually very sparse + // So we dont affix the tabs on these + if (bp.getBreakpointSize() === 'xs' || !$tabs.length) return; + + /** + If the browser does not support position sticky, it returns the position as static. + If the browser does support sticky, then we allow the browser to handle it, if not + then we default back to Bootstraps affix + **/ + if ($tabs.css('position') !== 'static') return; + + const $diffTabs = $('#diff-notes-app'); + + $tabs.off('affix.bs.affix affix-top.bs.affix') + .affix({ + offset: { + top: () => ( + $diffTabs.offset().top - $tabs.height() - $fixedNav.height() + ), + }, + }) + .on('affix.bs.affix', () => $diffTabs.css({ marginTop: $tabs.height() })) + .on('affix-top.bs.affix', () => $diffTabs.css({ marginTop: '' })); - initAffix() { - const $tabs = $('.js-tabs-affix'); - const $fixedNav = $('.navbar-gitlab'); - - // Screen space on small screens is usually very sparse - // So we dont affix the tabs on these - if (bp.getBreakpointSize() === 'xs' || !$tabs.length) return; - - /** - If the browser does not support position sticky, it returns the position as static. - If the browser does support sticky, then we allow the browser to handle it, if not - then we default back to Bootstraps affix - **/ - if ($tabs.css('position') !== 'static') return; - - const $diffTabs = $('#diff-notes-app'); - - $tabs.off('affix.bs.affix affix-top.bs.affix') - .affix({ - offset: { - top: () => ( - $diffTabs.offset().top - $tabs.height() - $fixedNav.height() - ), - }, - }) - .on('affix.bs.affix', () => $diffTabs.css({ marginTop: $tabs.height() })) - .on('affix-top.bs.affix', () => $diffTabs.css({ marginTop: '' })); - - // Fix bug when reloading the page already scrolling - if ($tabs.hasClass('affix')) { - $tabs.trigger('affix.bs.affix'); - } + // Fix bug when reloading the page already scrolling + if ($tabs.hasClass('affix')) { + $tabs.trigger('affix.bs.affix'); } } - - window.gl = window.gl || {}; - window.gl.MergeRequestTabs = MergeRequestTabs; -})(); +} diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index 74e5a4f1cea..2e5e818d61d 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -2,6 +2,7 @@ /* global Issuable */ /* global ListMilestone */ import _ from 'underscore'; +import { timeFor } from './lib/utils/datetime_utility'; (function() { this.MilestoneSelect = (function() { @@ -216,7 +217,7 @@ import _ from 'underscore'; $value.css('display', ''); if (data.milestone != null) { data.milestone.full_path = _this.currentProject.full_path; - data.milestone.remaining = gl.utils.timeFor(data.milestone.due_date); + data.milestone.remaining = timeFor(data.milestone.due_date); data.milestone.name = data.milestone.title; $value.html(milestoneLinkTemplate(data.milestone)); return $sidebarCollapsedValue.find('span').html(collapsedSidebarLabelTemplate(data.milestone)); diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue index cdae287658b..eede04a06cd 100644 --- a/app/assets/javascripts/monitoring/components/graph.vue +++ b/app/assets/javascripts/monitoring/components/graph.vue @@ -1,5 +1,8 @@ <script> - import d3 from 'd3'; + import { scaleLinear, scaleTime } from 'd3-scale'; + import { axisLeft, axisBottom } from 'd3-axis'; + import { max, extent } from 'd3-array'; + import { select } from 'd3-selection'; import GraphLegend from './graph/legend.vue'; import GraphFlag from './graph/flag.vue'; import GraphDeployment from './graph/deployment.vue'; @@ -7,10 +10,12 @@ import MonitoringMixin from '../mixins/monitoring_mixins'; import eventHub from '../event_hub'; import measurements from '../utils/measurements'; - import { timeScaleFormat, bisectDate } from '../utils/date_time_formatters'; + import { bisectDate, timeScaleFormat } from '../utils/date_time_formatters'; import createTimeSeries from '../utils/multiple_time_series'; import bp from '../../breakpoints'; + const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select }; + export default { props: { graphData: { @@ -156,25 +161,22 @@ this.baseGraphHeight = this.baseGraphHeight += (this.timeSeries.length - 3) * 20; } - const axisXScale = d3.time.scale() + const axisXScale = d3.scaleTime() .range([0, this.graphWidth - 70]); - const axisYScale = d3.scale.linear() + const axisYScale = d3.scaleLinear() .range([this.graphHeight - this.graphHeightOffset, 0]); const allValues = this.timeSeries.reduce((all, { values }) => all.concat(values), []); axisXScale.domain(d3.extent(allValues, d => d.time)); axisYScale.domain([0, d3.max(allValues.map(d => d.value))]); - const xAxis = d3.svg.axis() + const xAxis = d3.axisBottom() .scale(axisXScale) - .ticks(d3.time.minute, 60) - .tickFormat(timeScaleFormat) - .orient('bottom'); + .tickFormat(timeScaleFormat); - const yAxis = d3.svg.axis() + const yAxis = d3.axisLeft() .scale(axisYScale) - .ticks(measurements.yTicks) - .orient('left'); + .ticks(measurements.yTicks); d3.select(this.$refs.baseSvg).select('.x-axis').call(xAxis); diff --git a/app/assets/javascripts/monitoring/utils/date_time_formatters.js b/app/assets/javascripts/monitoring/utils/date_time_formatters.js index ad07a8465e2..48bdec1e030 100644 --- a/app/assets/javascripts/monitoring/utils/date_time_formatters.js +++ b/app/assets/javascripts/monitoring/utils/date_time_formatters.js @@ -1,17 +1,32 @@ -import d3 from 'd3'; +import { timeFormat as time } from 'd3-time-format'; +import { timeSecond, timeMinute, timeHour, timeDay, timeMonth, timeYear } from 'd3-time'; +import { bisector } from 'd3-array'; -export const dateFormat = d3.time.format('%b %-d, %Y'); -export const dateFormatWithName = d3.time.format('%a, %b %-d'); -export const timeFormat = d3.time.format('%-I:%M%p'); +const d3 = { time, bisector, timeSecond, timeMinute, timeHour, timeDay, timeMonth, timeYear }; + +export const dateFormat = d3.time('%b %-d, %Y'); +export const timeFormat = d3.time('%-I:%M%p'); +export const dateFormatWithName = d3.time('%a, %b %-d'); export const bisectDate = d3.bisector(d => d.time).left; -export const timeScaleFormat = d3.time.format.multi([ - ['.%L', d => d.getMilliseconds()], - [':%S', d => d.getSeconds()], - ['%-I:%M', d => d.getMinutes()], - ['%-I %p', d => d.getHours()], - ['%a %-d', d => d.getDay() && d.getDate() !== 1], - ['%b %-d', d => d.getDate() !== 1], - ['%B', d => d.getMonth()], - ['%Y', () => true], -]); +export function timeScaleFormat(date) { + let formatFunction; + if (d3.timeSecond(date) < date) { + formatFunction = d3.time('.%L'); + } else if (d3.timeMinute(date) < date) { + formatFunction = d3.time(':%S'); + } else if (d3.timeHour(date) < date) { + formatFunction = d3.time('%-I:%M'); + } else if (d3.timeDay(date) < date) { + formatFunction = d3.time('%-I %p'); + } else if (d3.timeWeek(date) < date) { + formatFunction = d3.time('%a %d'); + } else if (d3.timeMonth(date) < date) { + formatFunction = d3.time('%b %d'); + } else if (d3.timeYear(date) < date) { + formatFunction = d3.time('%B'); + } else { + formatFunction = d3.time('%Y'); + } + return formatFunction(date); +} diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js index d21a265bd43..4ce3dad440c 100644 --- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js +++ b/app/assets/javascripts/monitoring/utils/multiple_time_series.js @@ -1,5 +1,10 @@ -import d3 from 'd3'; import _ from 'underscore'; +import { scaleLinear, scaleTime } from 'd3-scale'; +import { line, area, curveLinear } from 'd3-shape'; +import { extent, max } from 'd3-array'; +import { timeMinute } from 'd3-time'; + +const d3 = { scaleLinear, scaleTime, line, area, curveLinear, extent, max, timeMinute }; const defaultColorPalette = { blue: ['#1f78d1', '#8fbce8'], @@ -38,27 +43,27 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom let lineColor = ''; let areaColor = ''; - const timeSeriesScaleX = d3.time.scale() + const timeSeriesScaleX = d3.scaleTime() .range([0, graphWidth - 70]); - const timeSeriesScaleY = d3.scale.linear() + const timeSeriesScaleY = d3.scaleLinear() .range([graphHeight - graphHeightOffset, 0]); timeSeriesScaleX.domain(xDom); - timeSeriesScaleX.ticks(d3.time.minute, 60); + timeSeriesScaleX.ticks(d3.timeMinute, 60); timeSeriesScaleY.domain(yDom); const defined = d => !isNaN(d.value) && d.value != null; - const lineFunction = d3.svg.line() + const lineFunction = d3.line() .defined(defined) - .interpolate('linear') + .curve(d3.curveLinear) // d3 v4 uses curbe instead of interpolate .x(d => timeSeriesScaleX(d.time)) .y(d => timeSeriesScaleY(d.value)); - const areaFunction = d3.svg.area() + const areaFunction = d3.area() .defined(defined) - .interpolate('linear') + .curve(d3.curveLinear) .x(d => timeSeriesScaleX(d.time)) .y0(graphHeight - graphHeightOffset) .y1(d => timeSeriesScaleY(d.value)); diff --git a/app/assets/javascripts/new_commit_form.js b/app/assets/javascripts/new_commit_form.js index 6e152497d20..a2f0a44863f 100644 --- a/app/assets/javascripts/new_commit_form.js +++ b/app/assets/javascripts/new_commit_form.js @@ -6,11 +6,12 @@ export default class NewCommitForm { this.branchName = form.find('.js-branch-name'); this.originalBranch = form.find('.js-original-branch'); this.createMergeRequest = form.find('.js-create-merge-request'); - this.createMergeRequestContainer = form.find('.js-create-merge-request-container'); + this.createMergeRequestContainer = form.find( + '.js-create-merge-request-container', + ); this.branchName.keyup(this.renderDestination); this.renderDestination(); } - renderDestination() { var different; different = this.branchName.val() !== this.originalBranch.val(); @@ -23,6 +24,6 @@ export default class NewCommitForm { this.createMergeRequestContainer.hide(); this.createMergeRequest.prop('checked', false); } - return this.wasDifferent = different; + return (this.wasDifferent = different); } } diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 2a570ac705e..a2b8e6f6495 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -25,6 +25,7 @@ import Autosave from './autosave'; import TaskList from './task_list'; import { ajaxPost, isInViewport, getPagePath, scrollToElement, isMetaKey } from './lib/utils/common_utils'; import imageDiffHelper from './image_diff/helpers/index'; +import { localTimeAgo } from './lib/utils/datetime_utility'; window.autosize = Autosize; @@ -36,6 +37,12 @@ const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm; export default class Notes { + static initialize(notes_url, note_ids, last_fetched_at, view, enableGFM = true) { + if (!this.instance) { + this.instance = new Notes(notes_url, note_ids, last_fetched_at, view, enableGFM); + } + } + constructor(notes_url, note_ids, last_fetched_at, view, enableGFM = true) { this.updateTargetButtons = this.updateTargetButtons.bind(this); this.updateComment = this.updateComment.bind(this); @@ -311,7 +318,7 @@ export default class Notes { setupNewNote($note) { // Update datetime format on the recent note - gl.utils.localTimeAgo($note.find('.js-timeago'), false); + localTimeAgo($note.find('.js-timeago'), false); this.collapseLongCommitList(); this.taskList.init(); @@ -463,7 +470,7 @@ export default class Notes { this.renderDiscussionAvatar(diffAvatarContainer, noteEntity); } - gl.utils.localTimeAgo($('.js-timeago'), false); + localTimeAgo($('.js-timeago'), false); Notes.checkMergeRequestStatus(); return this.updateNotesCount(1); } diff --git a/app/assets/javascripts/notifications_dropdown.js b/app/assets/javascripts/notifications_dropdown.js index f90ac2d9f71..9570d1c00aa 100644 --- a/app/assets/javascripts/notifications_dropdown.js +++ b/app/assets/javascripts/notifications_dropdown.js @@ -1,31 +1,25 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, one-var, no-var, one-var-declaration-per-line, no-unused-vars, consistent-return, prefer-arrow-callback, no-else-return, max-len */ import Flash from './flash'; -(function() { - this.NotificationsDropdown = (function() { - function NotificationsDropdown() { - $(document).off('click', '.update-notification').on('click', '.update-notification', function(e) { - var form, label, notificationLevel; - e.preventDefault(); - if ($(this).is('.is-active') && $(this).data('notification-level') === 'custom') { - return; - } - notificationLevel = $(this).data('notification-level'); - label = $(this).data('notification-title'); - form = $(this).parents('.notification-form:first'); - form.find('.js-notification-loading').toggleClass('fa-bell fa-spin fa-spinner'); - form.find('#notification_setting_level').val(notificationLevel); - return form.submit(); - }); - $(document).off('ajax:success', '.notification-form').on('ajax:success', '.notification-form', function(e, data) { - if (data.saved) { - return $(e.currentTarget).closest('.js-notification-dropdown').replaceWith(data.html); - } else { - return new Flash('Failed to save new settings', 'alert'); - } - }); +export default function notificationsDropdown() { + $(document).on('click', '.update-notification', function updateNotificationCallback(e) { + e.preventDefault(); + if ($(this).is('.is-active') && $(this).data('notification-level') === 'custom') { + return; } - return NotificationsDropdown; - })(); -}).call(window); + const notificationLevel = $(this).data('notification-level'); + const form = $(this).parents('.notification-form:first'); + + form.find('.js-notification-loading').toggleClass('fa-bell fa-spin fa-spinner'); + form.find('#notification_setting_level').val(notificationLevel); + form.submit(); + }); + + $(document).on('ajax:success', '.notification-form', (e, data) => { + if (data.saved) { + $(e.currentTarget).closest('.js-notification-dropdown').replaceWith(data.html); + } else { + Flash('Failed to save new settings', 'alert'); + } + }); +} diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js index 2ab9c4fed2c..4534360d577 100644 --- a/app/assets/javascripts/notifications_form.js +++ b/app/assets/javascripts/notifications_form.js @@ -1,55 +1,50 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, one-var-declaration-per-line, newline-per-chained-call, comma-dangle, consistent-return, prefer-arrow-callback, max-len */ -(function() { - this.NotificationsForm = (function() { - function NotificationsForm() { - this.toggleCheckbox = this.toggleCheckbox.bind(this); - this.removeEventListeners(); - this.initEventListeners(); - } +export default class NotificationsForm { + constructor() { + this.toggleCheckbox = this.toggleCheckbox.bind(this); + this.initEventListeners(); + } - NotificationsForm.prototype.removeEventListeners = function() { - return $(document).off('change', '.js-custom-notification-event'); - }; + initEventListeners() { + $(document).on('change', '.js-custom-notification-event', this.toggleCheckbox); + } - NotificationsForm.prototype.initEventListeners = function() { - return $(document).on('change', '.js-custom-notification-event', this.toggleCheckbox); - }; + toggleCheckbox(e) { + const $checkbox = $(e.currentTarget); + const $parent = $checkbox.closest('.checkbox'); - NotificationsForm.prototype.toggleCheckbox = function(e) { - var $checkbox, $parent; - $checkbox = $(e.currentTarget); - $parent = $checkbox.closest('.checkbox'); - return this.saveEvent($checkbox, $parent); - }; + this.saveEvent($checkbox, $parent); + } - NotificationsForm.prototype.showCheckboxLoadingSpinner = function($parent) { - return $parent.addClass('is-loading').find('.custom-notification-event-loading').removeClass('fa-check').addClass('fa-spin fa-spinner').removeClass('is-done'); - }; + // eslint-disable-next-line class-methods-use-this + showCheckboxLoadingSpinner($parent) { + $parent.addClass('is-loading') + .find('.custom-notification-event-loading') + .removeClass('fa-check') + .addClass('fa-spin fa-spinner') + .removeClass('is-done'); + } - NotificationsForm.prototype.saveEvent = function($checkbox, $parent) { - var form; - form = $parent.parents('form:first'); - return $.ajax({ - url: form.attr('action'), - method: form.attr('method'), - dataType: 'json', - data: form.serialize(), - beforeSend: (function(_this) { - return function() { - return _this.showCheckboxLoadingSpinner($parent); - }; - })(this) - }).done(function(data) { - $checkbox.enable(); - if (data.saved) { - $parent.find('.custom-notification-event-loading').toggleClass('fa-spin fa-spinner fa-check is-done'); - return setTimeout(function() { - return $parent.removeClass('is-loading').find('.custom-notification-event-loading').toggleClass('fa-spin fa-spinner fa-check is-done'); - }, 2000); - } - }); - }; + saveEvent($checkbox, $parent) { + const form = $parent.parents('form:first'); - return NotificationsForm; - })(); -}).call(window); + return $.ajax({ + url: form.attr('action'), + method: form.attr('method'), + dataType: 'json', + data: form.serialize(), + beforeSend: () => { + this.showCheckboxLoadingSpinner($parent); + }, + }).done((data) => { + $checkbox.enable(); + if (data.saved) { + $parent.find('.custom-notification-event-loading').toggleClass('fa-spin fa-spinner fa-check is-done'); + setTimeout(() => { + $parent.removeClass('is-loading') + .find('.custom-notification-event-loading') + .toggleClass('fa-spin fa-spinner fa-check is-done'); + }, 2000); + } + }); + } +} diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js index 6792b984cc5..6552a88b606 100644 --- a/app/assets/javascripts/pager.js +++ b/app/assets/javascripts/pager.js @@ -1,78 +1,74 @@ import { getParameterByName } from '~/lib/utils/common_utils'; import { removeParams } from './lib/utils/url_utility'; -(() => { - const ENDLESS_SCROLL_BOTTOM_PX = 400; - const ENDLESS_SCROLL_FIRE_DELAY_MS = 1000; +const ENDLESS_SCROLL_BOTTOM_PX = 400; +const ENDLESS_SCROLL_FIRE_DELAY_MS = 1000; - const Pager = { - init(limit = 0, preload = false, disable = false, prepareData = $.noop, callback = $.noop) { - this.url = $('.content_list').data('href') || removeParams(['limit', 'offset']); - this.limit = limit; - this.offset = parseInt(getParameterByName('offset'), 10) || this.limit; - this.disable = disable; - this.prepareData = prepareData; - this.callback = callback; - this.loading = $('.loading').first(); - if (preload) { - this.offset = 0; - this.getOld(); - } - this.initLoadMore(); - }, +export default { + init(limit = 0, preload = false, disable = false, prepareData = $.noop, callback = $.noop) { + this.url = $('.content_list').data('href') || removeParams(['limit', 'offset']); + this.limit = limit; + this.offset = parseInt(getParameterByName('offset'), 10) || this.limit; + this.disable = disable; + this.prepareData = prepareData; + this.callback = callback; + this.loading = $('.loading').first(); + if (preload) { + this.offset = 0; + this.getOld(); + } + this.initLoadMore(); + }, - getOld() { - this.loading.show(); - $.ajax({ - type: 'GET', - url: this.url, - data: `limit=${this.limit}&offset=${this.offset}`, - dataType: 'json', - error: () => this.loading.hide(), - success: (data) => { - this.append(data.count, this.prepareData(data.html)); - this.callback(); + getOld() { + this.loading.show(); + $.ajax({ + type: 'GET', + url: this.url, + data: `limit=${this.limit}&offset=${this.offset}`, + dataType: 'json', + error: () => this.loading.hide(), + success: (data) => { + this.append(data.count, this.prepareData(data.html)); + this.callback(); - // keep loading until we've filled the viewport height - if (!this.disable && !this.isScrollable()) { - this.getOld(); - } else { - this.loading.hide(); - } - }, - }); - }, + // keep loading until we've filled the viewport height + if (!this.disable && !this.isScrollable()) { + this.getOld(); + } else { + this.loading.hide(); + } + }, + }); + }, - append(count, html) { - $('.content_list').append(html); - if (count > 0) { - this.offset += count; - } else { - this.disable = true; - } - }, + append(count, html) { + $('.content_list').append(html); + if (count > 0) { + this.offset += count; + } else { + this.disable = true; + } + }, - isScrollable() { - const $w = $(window); - return $(document).height() > $w.height() + $w.scrollTop() + ENDLESS_SCROLL_BOTTOM_PX; - }, + isScrollable() { + const $w = $(window); + return $(document).height() > $w.height() + $w.scrollTop() + ENDLESS_SCROLL_BOTTOM_PX; + }, - initLoadMore() { - $(document).unbind('scroll'); - $(document).endlessScroll({ - bottomPixels: ENDLESS_SCROLL_BOTTOM_PX, - fireDelay: ENDLESS_SCROLL_FIRE_DELAY_MS, - fireOnce: true, - ceaseFire: () => this.disable === true, - callback: () => { - if (!this.loading.is(':visible')) { - this.loading.show(); - this.getOld(); - } - }, - }); - }, - }; - - window.Pager = Pager; -})(); + initLoadMore() { + $(document).unbind('scroll'); + $(document).endlessScroll({ + bottomPixels: ENDLESS_SCROLL_BOTTOM_PX, + fireDelay: ENDLESS_SCROLL_FIRE_DELAY_MS, + fireOnce: true, + ceaseFire: () => this.disable === true, + callback: () => { + if (!this.loading.is(':visible')) { + this.loading.show(); + this.getOld(); + } + }, + }); + }, +}; diff --git a/app/assets/javascripts/pages/users/show/index.js b/app/assets/javascripts/pages/users/show/index.js new file mode 100644 index 00000000000..f18f98b4e9a --- /dev/null +++ b/app/assets/javascripts/pages/users/show/index.js @@ -0,0 +1,3 @@ +import UserCallout from '~/user_callout'; + +export default () => new UserCallout(); diff --git a/app/assets/javascripts/preview_markdown.js b/app/assets/javascripts/preview_markdown.js index 141333b2b4d..ffaafb3ee9e 100644 --- a/app/assets/javascripts/preview_markdown.js +++ b/app/assets/javascripts/preview_markdown.js @@ -117,12 +117,10 @@ }()); markdownPreview = new window.MarkdownPreview(); - previewButtonSelector = '.js-md-preview-button'; - writeButtonSelector = '.js-md-write-button'; - lastTextareaPreviewed = null; + const markdownToolbar = $('.md-header-toolbar'); $.fn.setupMarkdownPreview = function () { var $form = $(this); @@ -146,6 +144,7 @@ // toggle content $form.find('.md-write-holder').hide(); $form.find('.md-preview-holder').show(); + markdownToolbar.removeClass('active'); markdownPreview.showPreview($form); }); @@ -167,6 +166,7 @@ $form.find('.md-write-holder').show(); $form.find('textarea.markdown-area').focus(); $form.find('.md-preview-holder').hide(); + markdownToolbar.addClass('active'); markdownPreview.hideReferencedCommands($form); }); diff --git a/app/assets/javascripts/profile/account/components/delete_account_modal.vue b/app/assets/javascripts/profile/account/components/delete_account_modal.vue index 6348a2e331d..78be6b6e884 100644 --- a/app/assets/javascripts/profile/account/components/delete_account_modal.vue +++ b/app/assets/javascripts/profile/account/components/delete_account_modal.vue @@ -1,5 +1,5 @@ <script> - import popupDialog from '../../../vue_shared/components/popup_dialog.vue'; + import modal from '../../../vue_shared/components/modal.vue'; import { __, s__, sprintf } from '../../../locale'; import csrf from '../../../lib/utils/csrf'; @@ -26,7 +26,7 @@ }; }, components: { - popupDialog, + modal, }, computed: { csrfToken() { @@ -89,7 +89,7 @@ Once you confirm %{deleteAccount}, it cannot be undone or recovered.`), <template> <div> - <popup-dialog + <modal v-if="isOpen" :title="s__('Profiles|Delete your account?')" :text="text" @@ -134,7 +134,7 @@ Once you confirm %{deleteAccount}, it cannot be undone or recovered.`), </form> </template> - </popup-dialog> + </modal> <button type="button" diff --git a/app/assets/javascripts/project_variables.js b/app/assets/javascripts/project_variables.js deleted file mode 100644 index 567c311f119..00000000000 --- a/app/assets/javascripts/project_variables.js +++ /dev/null @@ -1,39 +0,0 @@ - -const HIDDEN_VALUE_TEXT = '******'; - -export default class ProjectVariables { - constructor() { - this.$revealBtn = $('.js-btn-toggle-reveal-values'); - this.$revealBtn.on('click', this.toggleRevealState.bind(this)); - } - - toggleRevealState(e) { - e.preventDefault(); - - const oldStatus = this.$revealBtn.attr('data-status'); - let newStatus = 'hidden'; - let newAction = 'Reveal Values'; - - if (oldStatus === 'hidden') { - newStatus = 'revealed'; - newAction = 'Hide Values'; - } - - this.$revealBtn.attr('data-status', newStatus); - - const $variables = $('.variable-value'); - - $variables.each((_, variable) => { - const $variable = $(variable); - let newText = HIDDEN_VALUE_TEXT; - - if (newStatus === 'revealed') { - newText = $variable.attr('data-value'); - } - - $variable.text(newText); - }); - - this.$revealBtn.text(newAction); - } -} diff --git a/app/assets/javascripts/repo/components/commit_sidebar/list.vue b/app/assets/javascripts/repo/components/commit_sidebar/list.vue deleted file mode 100644 index fb862e7bf01..00000000000 --- a/app/assets/javascripts/repo/components/commit_sidebar/list.vue +++ /dev/null @@ -1,89 +0,0 @@ -<script> - import icon from '../../../vue_shared/components/icon.vue'; - import listItem from './list_item.vue'; - import listCollapsed from './list_collapsed.vue'; - - export default { - components: { - icon, - listItem, - listCollapsed, - }, - props: { - title: { - type: String, - required: true, - }, - fileList: { - type: Array, - required: true, - }, - collapsed: { - type: Boolean, - required: true, - }, - }, - methods: { - toggleCollapsed() { - this.$emit('toggleCollapsed'); - }, - }, - }; -</script> - -<template> - <div class="multi-file-commit-panel-section"> - <header - class="multi-file-commit-panel-header" - :class="{ - 'is-collapsed': collapsed, - }" - > - <icon - name="list-bulleted" - :size="18" - css-classes="append-right-default" - /> - <template v-if="!collapsed"> - {{ title }} - <button - type="button" - class="btn btn-transparent multi-file-commit-panel-collapse-btn" - @click="toggleCollapsed" - > - <i - aria-hidden="true" - class="fa fa-angle-double-right" - > - </i> - </button> - </template> - </header> - <div class="multi-file-commit-list"> - <list-collapsed - v-if="collapsed" - /> - <template v-else> - <ul - v-if="fileList.length" - class="list-unstyled append-bottom-0" - > - <li - v-for="file in fileList" - :key="file.key" - > - <list-item - :file="file" - /> - </li> - </ul> - <div - v-else - class="help-block prepend-top-0" - > - No changes - </div> - </template> - </div> - </div> -</template> diff --git a/app/assets/javascripts/repo/components/new_dropdown/index.vue b/app/assets/javascripts/repo/components/new_dropdown/index.vue deleted file mode 100644 index 781404cf8ca..00000000000 --- a/app/assets/javascripts/repo/components/new_dropdown/index.vue +++ /dev/null @@ -1,89 +0,0 @@ -<script> - import { mapState } from 'vuex'; - import newModal from './modal.vue'; - import upload from './upload.vue'; - import icon from '../../../vue_shared/components/icon.vue'; - - export default { - components: { - icon, - newModal, - upload, - }, - data() { - return { - openModal: false, - modalType: '', - }; - }, - computed: { - ...mapState([ - 'path', - ]), - }, - methods: { - createNewItem(type) { - this.modalType = type; - this.toggleModalOpen(); - }, - toggleModalOpen() { - this.openModal = !this.openModal; - }, - }, - }; -</script> - -<template> - <div> - <ul class="breadcrumb repo-breadcrumb"> - <li class="dropdown"> - <button - type="button" - class="btn btn-default dropdown-toggle add-to-tree" - data-toggle="dropdown" - aria-label="Create new file or directory" - > - <icon - name="plus" - css-classes="pull-left" - /> - <icon - name="arrow-down" - css-classes="pull-left" - /> - </button> - <ul class="dropdown-menu"> - <li> - <a - href="#" - role="button" - @click.prevent="createNewItem('blob')" - > - {{ __('New file') }} - </a> - </li> - <li> - <upload - :path="path" - /> - </li> - <li> - <a - href="#" - role="button" - @click.prevent="createNewItem('tree')" - > - {{ __('New directory') }} - </a> - </li> - </ul> - </li> - </ul> - <new-modal - v-if="openModal" - :type="modalType" - :path="path" - @toggle="toggleModalOpen" - /> - </div> -</template> diff --git a/app/assets/javascripts/repo/components/repo.vue b/app/assets/javascripts/repo/components/repo.vue deleted file mode 100644 index a00e1e9d809..00000000000 --- a/app/assets/javascripts/repo/components/repo.vue +++ /dev/null @@ -1,63 +0,0 @@ -<script> -import { mapState, mapGetters } from 'vuex'; -import RepoSidebar from './repo_sidebar.vue'; -import RepoCommitSection from './repo_commit_section.vue'; -import RepoTabs from './repo_tabs.vue'; -import RepoFileButtons from './repo_file_buttons.vue'; -import RepoPreview from './repo_preview.vue'; -import repoEditor from './repo_editor.vue'; - -export default { - computed: { - ...mapState([ - 'currentBlobView', - ]), - ...mapGetters([ - 'isCollapsed', - 'changedFiles', - ]), - }, - components: { - RepoSidebar, - RepoTabs, - RepoFileButtons, - repoEditor, - RepoCommitSection, - RepoPreview, - }, - mounted() { - const returnValue = 'Are you sure you want to lose unsaved changes?'; - window.onbeforeunload = (e) => { - if (!this.changedFiles.length) return undefined; - - Object.assign(e, { - returnValue, - }); - return returnValue; - }; - }, -}; -</script> - -<template> - <div - class="multi-file" - :class="{ - 'is-collapsed': isCollapsed - }" - > - <repo-sidebar/> - <div - v-if="isCollapsed" - class="multi-file-edit-pane" - > - <repo-tabs /> - <component - class="multi-file-edit-pane-content" - :is="currentBlobView" - /> - <repo-file-buttons /> - </div> - <repo-commit-section /> - </div> -</template> diff --git a/app/assets/javascripts/repo/components/repo_sidebar.vue b/app/assets/javascripts/repo/components/repo_sidebar.vue deleted file mode 100644 index 4ea21913129..00000000000 --- a/app/assets/javascripts/repo/components/repo_sidebar.vue +++ /dev/null @@ -1,85 +0,0 @@ -<script> -import { mapState, mapGetters, mapActions } from 'vuex'; -import RepoPreviousDirectory from './repo_prev_directory.vue'; -import RepoFile from './repo_file.vue'; -import RepoLoadingFile from './repo_loading_file.vue'; - -export default { - components: { - 'repo-previous-directory': RepoPreviousDirectory, - 'repo-file': RepoFile, - 'repo-loading-file': RepoLoadingFile, - }, - created() { - window.addEventListener('popstate', this.popHistoryState); - }, - destroyed() { - window.removeEventListener('popstate', this.popHistoryState); - }, - mounted() { - this.getTreeData(); - }, - computed: { - ...mapState([ - 'loading', - 'isRoot', - ]), - ...mapState({ - projectName(state) { - return state.project.name; - }, - }), - ...mapGetters([ - 'treeList', - 'isCollapsed', - ]), - }, - methods: { - ...mapActions([ - 'getTreeData', - 'popHistoryState', - ]), - }, -}; -</script> - -<template> -<div class="ide-file-list"> - <table class="table"> - <thead> - <tr> - <th - v-if="isCollapsed" - > - </th> - <template v-else> - <th class="name multi-file-table-name"> - Name - </th> - <th class="hidden-sm hidden-xs last-commit"> - Last commit - </th> - <th class="hidden-xs last-update text-right"> - Last update - </th> - </template> - </tr> - </thead> - <tbody> - <repo-previous-directory - v-if="!isRoot && treeList.length" - /> - <repo-loading-file - v-if="!treeList.length && loading" - v-for="n in 5" - :key="n" - /> - <repo-file - v-for="file in treeList" - :key="file.key" - :file="file" - /> - </tbody> - </table> -</div> -</template> diff --git a/app/assets/javascripts/repo/index.js b/app/assets/javascripts/repo/index.js deleted file mode 100644 index b6801af7fcb..00000000000 --- a/app/assets/javascripts/repo/index.js +++ /dev/null @@ -1,106 +0,0 @@ -import Vue from 'vue'; -import { mapActions } from 'vuex'; -import { convertPermissionToBoolean } from '../lib/utils/common_utils'; -import Repo from './components/repo.vue'; -import RepoEditButton from './components/repo_edit_button.vue'; -import newBranchForm from './components/new_branch_form.vue'; -import newDropdown from './components/new_dropdown/index.vue'; -import store from './stores'; -import Translate from '../vue_shared/translate'; - -function initRepo(el) { - if (!el) return null; - - return new Vue({ - el, - store, - components: { - repo: Repo, - }, - methods: { - ...mapActions([ - 'setInitialData', - ]), - }, - created() { - const data = el.dataset; - - this.setInitialData({ - project: { - id: data.projectId, - name: data.projectName, - url: data.projectUrl, - }, - endpoints: { - rootEndpoint: data.url, - newMergeRequestUrl: data.newMergeRequestUrl, - rootUrl: data.rootUrl, - }, - canCommit: convertPermissionToBoolean(data.canCommit), - onTopOfBranch: convertPermissionToBoolean(data.onTopOfBranch), - currentRef: data.ref, - path: data.currentPath, - currentBranch: data.currentBranch, - isRoot: convertPermissionToBoolean(data.root), - isInitialRoot: convertPermissionToBoolean(data.root), - }); - }, - render(createElement) { - return createElement('repo'); - }, - }); -} - -function initRepoEditButton(el) { - return new Vue({ - el, - store, - components: { - repoEditButton: RepoEditButton, - }, - render(createElement) { - return createElement('repo-edit-button'); - }, - }); -} - -function initNewDropdown(el) { - return new Vue({ - el, - store, - components: { - newDropdown, - }, - render(createElement) { - return createElement('new-dropdown'); - }, - }); -} - -function initNewBranchForm() { - const el = document.querySelector('.js-new-branch-dropdown'); - - if (!el) return null; - - return new Vue({ - el, - components: { - newBranchForm, - }, - store, - render(createElement) { - return createElement('new-branch-form'); - }, - }); -} - -const repo = document.getElementById('repo'); -const editButton = document.querySelector('.editable-mode'); -const newDropdownHolder = document.querySelector('.js-new-dropdown'); - -Vue.use(Translate); - -initRepo(repo); -initRepoEditButton(editButton); -initNewBranchForm(); -initNewDropdown(newDropdownHolder); diff --git a/app/assets/javascripts/repo/stores/actions.js b/app/assets/javascripts/repo/stores/actions.js deleted file mode 100644 index af5dcf054ef..00000000000 --- a/app/assets/javascripts/repo/stores/actions.js +++ /dev/null @@ -1,146 +0,0 @@ -import Vue from 'vue'; -import { visitUrl } from '../../lib/utils/url_utility'; -import flash from '../../flash'; -import service from '../services'; -import * as types from './mutation_types'; - -export const redirectToUrl = (_, url) => visitUrl(url); - -export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); - -export const closeDiscardPopup = ({ commit }) => commit(types.TOGGLE_DISCARD_POPUP, false); - -export const discardAllChanges = ({ commit, getters, dispatch }) => { - const changedFiles = getters.changedFiles; - - changedFiles.forEach((file) => { - commit(types.DISCARD_FILE_CHANGES, file); - - if (file.tempFile) { - dispatch('closeFile', { file, force: true }); - } - }); -}; - -export const closeAllFiles = ({ state, dispatch }) => { - state.openFiles.forEach(file => dispatch('closeFile', { file })); -}; - -export const toggleEditMode = ({ state, commit, getters, dispatch }, force = false) => { - const changedFiles = getters.changedFiles; - - if (changedFiles.length && !force) { - commit(types.TOGGLE_DISCARD_POPUP, true); - } else { - commit(types.TOGGLE_EDIT_MODE); - commit(types.TOGGLE_DISCARD_POPUP, false); - dispatch('toggleBlobView'); - - if (!state.editMode) { - dispatch('discardAllChanges'); - } - } -}; - -export const toggleBlobView = ({ commit, state }) => { - if (state.editMode) { - commit(types.SET_EDIT_MODE); - } else { - commit(types.SET_PREVIEW_MODE); - } -}; - -export const checkCommitStatus = ({ state }) => service.getBranchData( - state.project.id, - state.currentBranch, -) - .then((data) => { - const { id } = data.commit; - - if (state.currentRef !== id) { - return true; - } - - return false; - }) - .catch(() => flash('Error checking branch data. Please try again.')); - -export const commitChanges = ({ commit, state, dispatch, getters }, { payload, newMr }) => - service.commit(state.project.id, payload) - .then((data) => { - const { branch } = payload; - if (!data.short_id) { - flash(data.message); - return; - } - - const lastCommit = { - commit_path: `${state.project.url}/commit/${data.id}`, - commit: { - message: data.message, - authored_date: data.committed_date, - }, - }; - - flash(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice'); - - if (newMr) { - dispatch('redirectToUrl', `${state.endpoints.newMergeRequestUrl}${branch}`); - } else { - commit(types.SET_COMMIT_REF, data.id); - - getters.changedFiles.forEach((entry) => { - commit(types.SET_LAST_COMMIT_DATA, { - entry, - lastCommit, - }); - }); - - dispatch('discardAllChanges'); - dispatch('closeAllFiles'); - dispatch('toggleEditMode'); - - window.scrollTo(0, 0); - } - }) - .catch(() => flash('Error committing changes. Please try again.')); - -export const createTempEntry = ({ state, dispatch }, { name, type, content = '', base64 = false }) => { - if (type === 'tree') { - dispatch('createTempTree', name); - } else if (type === 'blob') { - dispatch('createTempFile', { - tree: state, - name, - base64, - content, - }); - } -}; - -export const popHistoryState = ({ state, dispatch, getters }) => { - const treeList = getters.treeList; - const tree = treeList.find(file => file.url === state.previousUrl); - - if (!tree) return; - - if (tree.type === 'tree') { - dispatch('toggleTreeOpen', { endpoint: tree.url, tree }); - } -}; - -export const scrollToTab = () => { - Vue.nextTick(() => { - const tabs = document.getElementById('tabs'); - - if (tabs) { - const tabEl = tabs.querySelector('.active .repo-tab'); - - tabEl.focus(); - } - }); -}; - -export * from './actions/tree'; -export * from './actions/file'; -export * from './actions/branch'; diff --git a/app/assets/javascripts/repo/stores/actions/branch.js b/app/assets/javascripts/repo/stores/actions/branch.js deleted file mode 100644 index 61d9a5af3e3..00000000000 --- a/app/assets/javascripts/repo/stores/actions/branch.js +++ /dev/null @@ -1,20 +0,0 @@ -import service from '../../services'; -import * as types from '../mutation_types'; -import { pushState } from '../utils'; - -// eslint-disable-next-line import/prefer-default-export -export const createNewBranch = ({ state, commit }, branch) => service.createBranch( - state.project.id, - { - branch, - ref: state.currentBranch, - }, -).then(res => res.json()) -.then((data) => { - const branchName = data.name; - const url = location.href.replace(state.currentBranch, branchName); - - pushState(url); - - commit(types.SET_CURRENT_BRANCH, branchName); -}); diff --git a/app/assets/javascripts/repo/stores/actions/tree.js b/app/assets/javascripts/repo/stores/actions/tree.js deleted file mode 100644 index 7c251e26bed..00000000000 --- a/app/assets/javascripts/repo/stores/actions/tree.js +++ /dev/null @@ -1,163 +0,0 @@ -import { visitUrl } from '../../../lib/utils/url_utility'; -import { normalizeHeaders } from '../../../lib/utils/common_utils'; -import flash from '../../../flash'; -import service from '../../services'; -import * as types from '../mutation_types'; -import { - pushState, - setPageTitle, - findEntry, - createTemp, - createOrMergeEntry, -} from '../utils'; - -export const getTreeData = ( - { commit, state, dispatch }, - { endpoint = state.endpoints.rootEndpoint, tree = state } = {}, -) => { - commit(types.TOGGLE_LOADING, tree); - - service.getTreeData(endpoint) - .then((res) => { - const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']); - - setPageTitle(pageTitle); - - return res.json(); - }) - .then((data) => { - const prevLastCommitPath = tree.lastCommitPath; - if (!state.isInitialRoot) { - commit(types.SET_ROOT, data.path === '/'); - } - - dispatch('updateDirectoryData', { data, tree }); - commit(types.SET_PARENT_TREE_URL, data.parent_tree_url); - commit(types.SET_LAST_COMMIT_URL, { tree, url: data.last_commit_path }); - commit(types.TOGGLE_LOADING, tree); - - if (prevLastCommitPath !== null) { - dispatch('getLastCommitData', tree); - } - - pushState(endpoint); - }) - .catch(() => { - flash('Error loading tree data. Please try again.'); - commit(types.TOGGLE_LOADING, tree); - }); -}; - -export const toggleTreeOpen = ({ commit, dispatch }, { endpoint, tree }) => { - if (tree.opened) { - // send empty data to clear the tree - const data = { trees: [], blobs: [], submodules: [] }; - - pushState(tree.parentTreeUrl); - - commit(types.SET_PREVIOUS_URL, tree.parentTreeUrl); - dispatch('updateDirectoryData', { data, tree }); - } else { - commit(types.SET_PREVIOUS_URL, endpoint); - dispatch('getTreeData', { endpoint, tree }); - } - - commit(types.TOGGLE_TREE_OPEN, tree); -}; - -export const clickedTreeRow = ({ commit, dispatch }, row) => { - if (row.type === 'tree') { - dispatch('toggleTreeOpen', { - endpoint: row.url, - tree: row, - }); - } else if (row.type === 'submodule') { - commit(types.TOGGLE_LOADING, row); - - visitUrl(row.url); - } else if (row.type === 'blob' && row.opened) { - dispatch('setFileActive', row); - } else { - dispatch('getFileData', row); - } -}; - -export const createTempTree = ({ state, commit, dispatch }, name) => { - let tree = state; - const dirNames = name.replace(new RegExp(`^${state.path}/`), '').split('/'); - - dirNames.forEach((dirName) => { - const foundEntry = findEntry(tree, 'tree', dirName); - - if (!foundEntry) { - const tmpEntry = createTemp({ - name: dirName, - path: tree.path, - type: 'tree', - level: tree.level !== undefined ? tree.level + 1 : 0, - }); - - commit(types.CREATE_TMP_TREE, { - parent: tree, - tmpEntry, - }); - commit(types.TOGGLE_TREE_OPEN, tmpEntry); - - tree = tmpEntry; - } else { - tree = foundEntry; - } - }); - - if (tree.tempFile) { - dispatch('createTempFile', { - tree, - name: '.gitkeep', - }); - } -}; - -export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = state) => { - if (tree.lastCommitPath === null || getters.isCollapsed) return; - - service.getTreeLastCommit(tree.lastCommitPath) - .then((res) => { - const lastCommitPath = normalizeHeaders(res.headers)['MORE-LOGS-URL'] || null; - - commit(types.SET_LAST_COMMIT_URL, { tree, url: lastCommitPath }); - - return res.json(); - }) - .then((data) => { - data.forEach((lastCommit) => { - const entry = findEntry(tree, lastCommit.type, lastCommit.file_name); - - if (entry) { - commit(types.SET_LAST_COMMIT_DATA, { entry, lastCommit }); - } - }); - - dispatch('getLastCommitData', tree); - }) - .catch(() => flash('Error fetching log data.')); -}; - -export const updateDirectoryData = ({ commit, state }, { data, tree }) => { - const level = tree.level !== undefined ? tree.level + 1 : 0; - const parentTreeUrl = data.parent_tree_url ? `${data.parent_tree_url}${data.path}` : state.endpoints.rootUrl; - const createEntry = (entry, type) => createOrMergeEntry({ - tree, - entry, - level, - type, - parentTreeUrl, - }); - - const formattedData = [ - ...data.trees.map(t => createEntry(t, 'tree')), - ...data.submodules.map(m => createEntry(m, 'submodule')), - ...data.blobs.map(b => createEntry(b, 'blob')), - ]; - - commit(types.SET_DIRECTORY_DATA, { tree, data: formattedData }); -}; diff --git a/app/assets/javascripts/repo/stores/getters.js b/app/assets/javascripts/repo/stores/getters.js deleted file mode 100644 index 5ce9f449905..00000000000 --- a/app/assets/javascripts/repo/stores/getters.js +++ /dev/null @@ -1,40 +0,0 @@ -import _ from 'underscore'; - -/* - Takes the multi-dimensional tree and returns a flattened array. - This allows for the table to recursively render the table rows but keeps the data - structure nested to make it easier to add new files/directories. -*/ -export const treeList = (state) => { - const mapTree = arr => (!arr.tree.length ? [] : _.map(arr.tree, a => [a, mapTree(a)])); - - return _.chain(state.tree) - .map(arr => [arr, mapTree(arr)]) - .flatten() - .value(); -}; - -export const changedFiles = state => state.openFiles.filter(file => file.changed); - -export const activeFile = state => state.openFiles.find(file => file.active); - -export const activeFileExtension = (state) => { - const file = activeFile(state); - return file ? `.${file.path.split('.').pop()}` : ''; -}; - -export const isCollapsed = state => !!state.openFiles.length; - -export const canEditFile = (state) => { - const currentActiveFile = activeFile(state); - const openedFiles = state.openFiles; - - return state.canCommit && - state.onTopOfBranch && - openedFiles.length && - (currentActiveFile && !currentActiveFile.renderError && !currentActiveFile.binary); -}; - -export const addedFiles = state => changedFiles(state).filter(f => f.tempFile); - -export const modifiedFiles = state => changedFiles(state).filter(f => !f.tempFile); diff --git a/app/assets/javascripts/repo/stores/mutations/branch.js b/app/assets/javascripts/repo/stores/mutations/branch.js deleted file mode 100644 index d8229e8a620..00000000000 --- a/app/assets/javascripts/repo/stores/mutations/branch.js +++ /dev/null @@ -1,9 +0,0 @@ -import * as types from '../mutation_types'; - -export default { - [types.SET_CURRENT_BRANCH](state, currentBranch) { - Object.assign(state, { - currentBranch, - }); - }, -}; diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index ec85b8b6529..b830fcf7e80 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -3,226 +3,228 @@ import _ from 'underscore'; import Cookies from 'js-cookie'; -(function() { - this.Sidebar = (function() { - function Sidebar(currentUser) { - this.toggleTodo = this.toggleTodo.bind(this); - this.sidebar = $('aside'); - - this.removeListeners(); - this.addEventListeners(); +function Sidebar(currentUser) { + this.toggleTodo = this.toggleTodo.bind(this); + this.sidebar = $('aside'); + + this.removeListeners(); + this.addEventListeners(); +} + +Sidebar.initialize = function(currentUser) { + if (!this.instance) { + this.instance = new Sidebar(currentUser); + } +}; + +Sidebar.prototype.removeListeners = function () { + this.sidebar.off('click', '.sidebar-collapsed-icon'); + this.sidebar.off('hidden.gl.dropdown'); + $('.dropdown').off('loading.gl.dropdown'); + $('.dropdown').off('loaded.gl.dropdown'); + $(document).off('click', '.js-sidebar-toggle'); +}; + +Sidebar.prototype.addEventListeners = function() { + const $document = $(document); + + this.sidebar.on('click', '.sidebar-collapsed-icon', this, this.sidebarCollapseClicked); + this.sidebar.on('hidden.gl.dropdown', this, this.onSidebarDropdownHidden); + $('.dropdown').on('loading.gl.dropdown', this.sidebarDropdownLoading); + $('.dropdown').on('loaded.gl.dropdown', this.sidebarDropdownLoaded); + + $document.on('click', '.js-sidebar-toggle', this.sidebarToggleClicked); + return $(document).off('click', '.js-issuable-todo').on('click', '.js-issuable-todo', this.toggleTodo); +}; + +Sidebar.prototype.sidebarToggleClicked = function (e, triggered) { + var $allGutterToggleIcons, $this, $thisIcon; + e.preventDefault(); + $this = $(this); + $thisIcon = $this.find('i'); + $allGutterToggleIcons = $('.js-sidebar-toggle i'); + if ($thisIcon.hasClass('fa-angle-double-right')) { + $allGutterToggleIcons.removeClass('fa-angle-double-right').addClass('fa-angle-double-left'); + $('aside.right-sidebar').removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); + $('.layout-page').removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); + } else { + $allGutterToggleIcons.removeClass('fa-angle-double-left').addClass('fa-angle-double-right'); + $('aside.right-sidebar').removeClass('right-sidebar-collapsed').addClass('right-sidebar-expanded'); + $('.layout-page').removeClass('right-sidebar-collapsed').addClass('right-sidebar-expanded'); + + if (gl.lazyLoader) gl.lazyLoader.loadCheck(); + } + if (!triggered) { + Cookies.set("collapsed_gutter", $('.right-sidebar').hasClass('right-sidebar-collapsed')); + } +}; + +Sidebar.prototype.toggleTodo = function(e) { + var $btnText, $this, $todoLoading, ajaxType, url; + $this = $(e.currentTarget); + ajaxType = $this.attr('data-delete-path') ? 'DELETE' : 'POST'; + if ($this.attr('data-delete-path')) { + url = "" + ($this.attr('data-delete-path')); + } else { + url = "" + ($this.data('url')); + } + + $this.tooltip('hide'); + + return $.ajax({ + url: url, + type: ajaxType, + dataType: 'json', + data: { + issuable_id: $this.data('issuable-id'), + issuable_type: $this.data('issuable-type') + }, + beforeSend: (function(_this) { + return function() { + $('.js-issuable-todo').disable() + .addClass('is-loading'); + }; + })(this) + }).done((function(_this) { + return function(data) { + return _this.todoUpdateDone(data); + }; + })(this)); +}; + +Sidebar.prototype.todoUpdateDone = function(data) { + const deletePath = data.delete_path ? data.delete_path : null; + const attrPrefix = deletePath ? 'mark' : 'todo'; + const $todoBtns = $('.js-issuable-todo'); + + $(document).trigger('todo:toggle', data.count); + + $todoBtns.each((i, el) => { + const $el = $(el); + const $elText = $el.find('.js-issuable-todo-inner'); + + $el.removeClass('is-loading') + .enable() + .attr('aria-label', $el.data(`${attrPrefix}-text`)) + .attr('data-delete-path', deletePath) + .attr('title', $el.data(`${attrPrefix}-text`)); + + if ($el.hasClass('has-tooltip')) { + $el.tooltip('fixTitle'); } - Sidebar.prototype.removeListeners = function () { - this.sidebar.off('click', '.sidebar-collapsed-icon'); - this.sidebar.off('hidden.gl.dropdown'); - $('.dropdown').off('loading.gl.dropdown'); - $('.dropdown').off('loaded.gl.dropdown'); - $(document).off('click', '.js-sidebar-toggle'); - }; - - Sidebar.prototype.addEventListeners = function() { - const $document = $(document); - - this.sidebar.on('click', '.sidebar-collapsed-icon', this, this.sidebarCollapseClicked); - this.sidebar.on('hidden.gl.dropdown', this, this.onSidebarDropdownHidden); - $('.dropdown').on('loading.gl.dropdown', this.sidebarDropdownLoading); - $('.dropdown').on('loaded.gl.dropdown', this.sidebarDropdownLoaded); - - $document.on('click', '.js-sidebar-toggle', this.sidebarToggleClicked); - return $(document).off('click', '.js-issuable-todo').on('click', '.js-issuable-todo', this.toggleTodo); - }; - - Sidebar.prototype.sidebarToggleClicked = function (e, triggered) { - var $allGutterToggleIcons, $this, $thisIcon; - e.preventDefault(); - $this = $(this); - $thisIcon = $this.find('i'); - $allGutterToggleIcons = $('.js-sidebar-toggle i'); - if ($thisIcon.hasClass('fa-angle-double-right')) { - $allGutterToggleIcons.removeClass('fa-angle-double-right').addClass('fa-angle-double-left'); - $('aside.right-sidebar').removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); - $('.layout-page').removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); - } else { - $allGutterToggleIcons.removeClass('fa-angle-double-left').addClass('fa-angle-double-right'); - $('aside.right-sidebar').removeClass('right-sidebar-collapsed').addClass('right-sidebar-expanded'); - $('.layout-page').removeClass('right-sidebar-collapsed').addClass('right-sidebar-expanded'); - - if (gl.lazyLoader) gl.lazyLoader.loadCheck(); - } - if (!triggered) { - Cookies.set("collapsed_gutter", $('.right-sidebar').hasClass('right-sidebar-collapsed')); - } - }; - - Sidebar.prototype.toggleTodo = function(e) { - var $btnText, $this, $todoLoading, ajaxType, url; - $this = $(e.currentTarget); - ajaxType = $this.attr('data-delete-path') ? 'DELETE' : 'POST'; - if ($this.attr('data-delete-path')) { - url = "" + ($this.attr('data-delete-path')); - } else { - url = "" + ($this.data('url')); - } - - $this.tooltip('hide'); - - return $.ajax({ - url: url, - type: ajaxType, - dataType: 'json', - data: { - issuable_id: $this.data('issuable-id'), - issuable_type: $this.data('issuable-type') - }, - beforeSend: (function(_this) { - return function() { - $('.js-issuable-todo').disable() - .addClass('is-loading'); - }; - })(this) - }).done((function(_this) { - return function(data) { - return _this.todoUpdateDone(data); - }; - })(this)); - }; - - Sidebar.prototype.todoUpdateDone = function(data) { - const deletePath = data.delete_path ? data.delete_path : null; - const attrPrefix = deletePath ? 'mark' : 'todo'; - const $todoBtns = $('.js-issuable-todo'); - - $(document).trigger('todo:toggle', data.count); - - $todoBtns.each((i, el) => { - const $el = $(el); - const $elText = $el.find('.js-issuable-todo-inner'); - - $el.removeClass('is-loading') - .enable() - .attr('aria-label', $el.data(`${attrPrefix}-text`)) - .attr('data-delete-path', deletePath) - .attr('title', $el.data(`${attrPrefix}-text`)); - - if ($el.hasClass('has-tooltip')) { - $el.tooltip('fixTitle'); - } - - if ($el.data(`${attrPrefix}-icon`)) { - $elText.html($el.data(`${attrPrefix}-icon`)); - } else { - $elText.text($el.data(`${attrPrefix}-text`)); - } - }); - }; - - Sidebar.prototype.sidebarDropdownLoading = function(e) { - var $loading, $sidebarCollapsedIcon, i, img; - $sidebarCollapsedIcon = $(this).closest('.block').find('.sidebar-collapsed-icon'); - img = $sidebarCollapsedIcon.find('img'); - i = $sidebarCollapsedIcon.find('i'); - $loading = $('<i class="fa fa-spinner fa-spin"></i>'); - if (img.length) { - img.before($loading); - return img.hide(); - } else if (i.length) { - i.before($loading); - return i.hide(); - } - }; - - Sidebar.prototype.sidebarDropdownLoaded = function(e) { - var $sidebarCollapsedIcon, i, img; - $sidebarCollapsedIcon = $(this).closest('.block').find('.sidebar-collapsed-icon'); - img = $sidebarCollapsedIcon.find('img'); - $sidebarCollapsedIcon.find('i.fa-spin').remove(); - i = $sidebarCollapsedIcon.find('i'); - if (img.length) { - return img.show(); - } else { - return i.show(); - } - }; - - Sidebar.prototype.sidebarCollapseClicked = function(e) { - var $block, sidebar; - if ($(e.currentTarget).hasClass('dont-change-state')) { - return; - } - sidebar = e.data; - e.preventDefault(); - $block = $(this).closest('.block'); - return sidebar.openDropdown($block); - }; - - Sidebar.prototype.openDropdown = function(blockOrName) { - var $block; - $block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName; - if (!this.isOpen()) { - this.setCollapseAfterUpdate($block); - this.toggleSidebar('open'); - } - - // Wait for the sidebar to trigger('click') open - // so it doesn't cause our dropdown to close preemptively - setTimeout(() => { - $block.find('.js-sidebar-dropdown-toggle').trigger('click'); - }); - }; - - Sidebar.prototype.setCollapseAfterUpdate = function($block) { - $block.addClass('collapse-after-update'); - return $('.layout-page').addClass('with-overlay'); - }; - - Sidebar.prototype.onSidebarDropdownHidden = function(e) { - var $block, sidebar; - sidebar = e.data; - e.preventDefault(); - $block = $(e.target).closest('.block'); - return sidebar.sidebarDropdownHidden($block); - }; - - Sidebar.prototype.sidebarDropdownHidden = function($block) { - if ($block.hasClass('collapse-after-update')) { - $block.removeClass('collapse-after-update'); - $('.layout-page').removeClass('with-overlay'); - return this.toggleSidebar('hide'); - } - }; - - Sidebar.prototype.triggerOpenSidebar = function() { - return this.sidebar.find('.js-sidebar-toggle').trigger('click'); - }; - - Sidebar.prototype.toggleSidebar = function(action) { - if (action == null) { - action = 'toggle'; - } - if (action === 'toggle') { - this.triggerOpenSidebar(); - } - if (action === 'open') { - if (!this.isOpen()) { - this.triggerOpenSidebar(); - } - } - if (action === 'hide') { - if (this.isOpen()) { - return this.triggerOpenSidebar(); - } - } - }; + if ($el.data(`${attrPrefix}-icon`)) { + $elText.html($el.data(`${attrPrefix}-icon`)); + } else { + $elText.text($el.data(`${attrPrefix}-text`)); + } + }); +}; + +Sidebar.prototype.sidebarDropdownLoading = function(e) { + var $loading, $sidebarCollapsedIcon, i, img; + $sidebarCollapsedIcon = $(this).closest('.block').find('.sidebar-collapsed-icon'); + img = $sidebarCollapsedIcon.find('img'); + i = $sidebarCollapsedIcon.find('i'); + $loading = $('<i class="fa fa-spinner fa-spin"></i>'); + if (img.length) { + img.before($loading); + return img.hide(); + } else if (i.length) { + i.before($loading); + return i.hide(); + } +}; + +Sidebar.prototype.sidebarDropdownLoaded = function(e) { + var $sidebarCollapsedIcon, i, img; + $sidebarCollapsedIcon = $(this).closest('.block').find('.sidebar-collapsed-icon'); + img = $sidebarCollapsedIcon.find('img'); + $sidebarCollapsedIcon.find('i.fa-spin').remove(); + i = $sidebarCollapsedIcon.find('i'); + if (img.length) { + return img.show(); + } else { + return i.show(); + } +}; + +Sidebar.prototype.sidebarCollapseClicked = function(e) { + var $block, sidebar; + if ($(e.currentTarget).hasClass('dont-change-state')) { + return; + } + sidebar = e.data; + e.preventDefault(); + $block = $(this).closest('.block'); + return sidebar.openDropdown($block); +}; + +Sidebar.prototype.openDropdown = function(blockOrName) { + var $block; + $block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName; + if (!this.isOpen()) { + this.setCollapseAfterUpdate($block); + this.toggleSidebar('open'); + } + + // Wait for the sidebar to trigger('click') open + // so it doesn't cause our dropdown to close preemptively + setTimeout(() => { + $block.find('.js-sidebar-dropdown-toggle').trigger('click'); + }); +}; + +Sidebar.prototype.setCollapseAfterUpdate = function($block) { + $block.addClass('collapse-after-update'); + return $('.layout-page').addClass('with-overlay'); +}; + +Sidebar.prototype.onSidebarDropdownHidden = function(e) { + var $block, sidebar; + sidebar = e.data; + e.preventDefault(); + $block = $(e.target).closest('.block'); + return sidebar.sidebarDropdownHidden($block); +}; + +Sidebar.prototype.sidebarDropdownHidden = function($block) { + if ($block.hasClass('collapse-after-update')) { + $block.removeClass('collapse-after-update'); + $('.layout-page').removeClass('with-overlay'); + return this.toggleSidebar('hide'); + } +}; + +Sidebar.prototype.triggerOpenSidebar = function() { + return this.sidebar.find('.js-sidebar-toggle').trigger('click'); +}; + +Sidebar.prototype.toggleSidebar = function(action) { + if (action == null) { + action = 'toggle'; + } + if (action === 'toggle') { + this.triggerOpenSidebar(); + } + if (action === 'open') { + if (!this.isOpen()) { + this.triggerOpenSidebar(); + } + } + if (action === 'hide') { + if (this.isOpen()) { + return this.triggerOpenSidebar(); + } + } +}; - Sidebar.prototype.isOpen = function() { - return this.sidebar.is('.right-sidebar-expanded'); - }; +Sidebar.prototype.isOpen = function() { + return this.sidebar.is('.right-sidebar-expanded'); +}; - Sidebar.prototype.getBlock = function(name) { - return this.sidebar.find(".block." + name); - }; +Sidebar.prototype.getBlock = function(name) { + return this.sidebar.find(".block." + name); +}; - return Sidebar; - })(); -}).call(window); +export default Sidebar; diff --git a/app/assets/javascripts/shortcuts.js b/app/assets/javascripts/shortcuts.js index 130730b1700..d2f0d7410da 100644 --- a/app/assets/javascripts/shortcuts.js +++ b/app/assets/javascripts/shortcuts.js @@ -51,7 +51,10 @@ export default class Shortcuts { } onToggleHelp(e) { - e.preventDefault(); + if (e.preventDefault) { + e.preventDefault(); + } + Shortcuts.toggleHelp(this.enabledHelp); } @@ -112,6 +115,9 @@ export default class Shortcuts { static focusSearch(e) { $('#search').focus(); - e.preventDefault(); + + if (e.preventDefault) { + e.preventDefault(); + } } } diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js index 305f97b010e..292e3d6a657 100644 --- a/app/assets/javascripts/shortcuts_issuable.js +++ b/app/assets/javascripts/shortcuts_issuable.js @@ -1,8 +1,8 @@ /* global Mousetrap */ -/* global sidebar */ import _ from 'underscore'; import 'mousetrap'; +import Sidebar from './right_sidebar'; import ShortcutsNavigation from './shortcuts_navigation'; import { CopyAsGFM } from './behaviors/copy_as_gfm'; @@ -69,7 +69,7 @@ export default class ShortcutsIssuable extends ShortcutsNavigation { } static openSidebarDropdown(name) { - sidebar.openDropdown(name); + Sidebar.instance.openDropdown(name); return false; } } diff --git a/app/assets/javascripts/users/activity_calendar.js b/app/assets/javascripts/users/activity_calendar.js index 5e947769f8a..0581239d5a5 100644 --- a/app/assets/javascripts/users/activity_calendar.js +++ b/app/assets/javascripts/users/activity_calendar.js @@ -1,5 +1,9 @@ import _ from 'underscore'; -import d3 from 'd3'; +import { scaleLinear, scaleThreshold } from 'd3-scale'; +import { select } from 'd3-selection'; +import { getDayName, getDayDifference } from '../lib/utils/datetime_utility'; + +const d3 = { select, scaleLinear, scaleThreshold }; const LOADING_HTML = ` <div class="text-center"> @@ -17,7 +21,7 @@ function getSystemDate(systemUtcOffsetSeconds) { function formatTooltipText({ date, count }) { const dateObject = new Date(date); - const dateDayName = gl.utils.getDayName(dateObject); + const dateDayName = getDayName(dateObject); const dateText = dateObject.format('mmm d, yyyy'); let contribText = 'No contributions'; @@ -27,7 +31,7 @@ function formatTooltipText({ date, count }) { return `${contribText}<br />${dateDayName} ${dateText}`; } -const initColorKey = () => d3.scale.linear().range(['#acd5f2', '#254e77']).domain([0, 3]); +const initColorKey = () => d3.scaleLinear().range(['#acd5f2', '#254e77']).domain([0, 3]); export default class ActivityCalendar { constructor(container, timestamps, calendarActivitiesPath, utcOffset = 0) { @@ -51,7 +55,7 @@ export default class ActivityCalendar { const oneYearAgo = new Date(today); oneYearAgo.setFullYear(today.getFullYear() - 1); - const days = gl.utils.getDayDifference(oneYearAgo, today); + const days = getDayDifference(oneYearAgo, today); for (let i = 0; i <= days; i += 1) { const date = new Date(oneYearAgo); @@ -204,7 +208,7 @@ export default class ActivityCalendar { initColor() { const colorRange = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)]; - return d3.scale.threshold().domain([0, 10, 20, 30]).range(colorRange); + return d3.scaleThreshold().domain([0, 10, 20, 30]).range(colorRange); } clickDay(stamp) { diff --git a/app/assets/javascripts/users/user_tabs.js b/app/assets/javascripts/users/user_tabs.js index 1215b265e28..992baa9a1ef 100644 --- a/app/assets/javascripts/users/user_tabs.js +++ b/app/assets/javascripts/users/user_tabs.js @@ -1,4 +1,6 @@ +import Activities from '../activities'; import ActivityCalendar from './activity_calendar'; +import { localTimeAgo } from '../lib/utils/datetime_utility'; /** * UserTabs @@ -138,7 +140,7 @@ export default class UserTabs { const tabSelector = `div#${action}`; this.$parentEl.find(tabSelector).html(data.html); this.loaded[action] = true; - gl.utils.localTimeAgo($('.js-timeago', tabSelector)); + localTimeAgo($('.js-timeago', tabSelector)); }, }); } @@ -169,7 +171,7 @@ export default class UserTabs { }); // eslint-disable-next-line no-new - new gl.Activities(); + new Activities(); this.loaded.activity = true; } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js index 32028a4a609..ee1a45cc754 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js @@ -1,4 +1,4 @@ -import '~/lib/utils/datetime_utility'; +import { getTimeago } from '~/lib/utils/datetime_utility'; import { visitUrl } from '../../lib/utils/url_utility'; import Flash from '../../flash'; import MemoryUsage from './mr_widget_memory_usage'; @@ -17,7 +17,7 @@ export default { }, methods: { formatDate(date) { - return gl.utils.getTimeago().format(date); + return getTimeago().format(date); }, hasExternalUrls(deployment = {}) { return deployment.external_url && deployment.external_url_formatted; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js index 05c4a28be88..43b2d238f65 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js @@ -65,10 +65,12 @@ export default { <div class="mr-widget-body media"> <status-icon status="success" /> <div class="media-body"> - <h4> - Set by - <mr-widget-author :author="mr.setToMWPSBy" /> - to be merged automatically when the pipeline succeeds + <h4 class="flex-container-block"> + <span class="append-right-10"> + Set by + <mr-widget-author :author="mr.setToMWPSBy" /> + to be merged automatically when the pipeline succeeds + </span> <a v-if="mr.canCancelAutomaticMerge" @click.prevent="cancelAutomaticMerge" @@ -94,8 +96,13 @@ export default { <p v-if="mr.shouldRemoveSourceBranch"> The source branch will be removed </p> - <p v-else> - The source branch will not be removed + <p + v-else + class="flex-container-block" + > + <span class="append-right-10"> + The source branch will not be removed + </span> <a v-if="canRemoveSourceBranch" :disabled="isRemovingSourceBranch" diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js index 9cb3edead86..8a9129c385b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js @@ -62,7 +62,7 @@ export default { return this.mr.hasCI; }, shouldRenderRelatedLinks() { - return !!this.mr.relatedLinks; + return !!this.mr.relatedLinks && !this.mr.isNothingToMergeState; }, shouldRenderDeployments() { return this.mr.deployments.length; diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js index 99f5c305df5..5fa838baba3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js +++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js @@ -6,7 +6,7 @@ Vue.use(VueResource); export default class MRWidgetService { constructor(endpoints) { this.mergeResource = Vue.resource(endpoints.mergePath); - this.mergeCheckResource = Vue.resource(endpoints.statusPath); + this.mergeCheckResource = Vue.resource(`${endpoints.statusPath}?serializer=widget`); this.cancelAutoMergeResource = Vue.resource(endpoints.cancelAutoMergePath); this.removeWIPResource = Vue.resource(endpoints.removeWIPPath); this.removeSourceBranchResource = Vue.resource(endpoints.sourceBranchPath); diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index 7c15abfff10..2bace3311c8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -1,30 +1,32 @@ +import { stateKey } from './state_maps'; + export default function deviseState(data) { if (data.project_archived) { - return 'archived'; + return stateKey.archived; } else if (data.branch_missing) { - return 'missingBranch'; + return stateKey.missingBranch; } else if (!data.commits_count) { - return 'nothingToMerge'; + return stateKey.nothingToMerge; } else if (this.mergeStatus === 'unchecked') { - return 'checking'; + return stateKey.checking; } else if (data.has_conflicts) { - return 'conflicts'; + return stateKey.conflicts; } else if (data.work_in_progress) { - return 'workInProgress'; + return stateKey.workInProgress; } else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) { - return 'pipelineFailed'; + return stateKey.pipelineFailed; } else if (this.hasMergeableDiscussionsState) { - return 'unresolvedDiscussions'; + return stateKey.unresolvedDiscussions; } else if (this.isPipelineBlocked) { - return 'pipelineBlocked'; + return stateKey.pipelineBlocked; } else if (this.hasSHAChanged) { - return 'shaMismatch'; + return stateKey.shaMismatch; } else if (this.mergeWhenPipelineSucceeds) { - return this.mergeError ? 'autoMergeFailed' : 'mergeWhenPipelineSucceeds'; + return this.mergeError ? stateKey.autoMergeFailed : stateKey.mergeWhenPipelineSucceeds; } else if (!this.canMerge) { - return 'notAllowedToMerge'; + return stateKey.notAllowedToMerge; } else if (this.canBeMerged) { - return 'readyToMerge'; + return stateKey.readyToMerge; } return null; } diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index c1f7e64f580..93d31a2a684 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -1,5 +1,7 @@ import Timeago from 'timeago.js'; import { getStateKey } from '../dependencies'; +import { stateKey } from './state_maps'; +import { formatDate } from '../../lib/utils/datetime_utility'; export default class MergeRequestStore { @@ -119,10 +121,14 @@ export default class MergeRequestStore { } } + get isNothingToMergeState() { + return this.state === stateKey.nothingToMerge; + } + static getEventObject(event) { return { author: MergeRequestStore.getAuthorObject(event), - updatedAt: gl.utils.formatDate(MergeRequestStore.getEventUpdatedAtDate(event)), + updatedAt: formatDate(MergeRequestStore.getEventUpdatedAtDate(event)), formattedUpdatedAt: MergeRequestStore.getEventDate(event), }; } diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js index 9074a064a6d..de980c175fb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js @@ -31,6 +31,23 @@ const statesToShowHelpWidget = [ 'autoMergeFailed', ]; +export const stateKey = { + archived: 'archived', + missingBranch: 'missingBranch', + nothingToMerge: 'nothingToMerge', + checking: 'checking', + conflicts: 'conflicts', + workInProgress: 'workInProgress', + pipelineFailed: 'pipelineFailed', + unresolvedDiscussions: 'unresolvedDiscussions', + pipelineBlocked: 'pipelineBlocked', + shaMismatch: 'shaMismatch', + autoMergeFailed: 'autoMergeFailed', + mergeWhenPipelineSucceeds: 'mergeWhenPipelineSucceeds', + notAllowedToMerge: 'notAllowedToMerge', + readyToMerge: 'readyToMerge', +}; + export default { stateToComponentMap, statesToShowHelpWidget, diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 6c575d8eb49..36d2d1dc164 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -72,7 +72,9 @@ Preview </a> </li> - <li class="md-header-toolbar"> + <li + class="md-header-toolbar" + :class="{ active: !previewMarkdown }"> <toolbar-button tag="**" button-title="Add bold text" diff --git a/app/assets/javascripts/vue_shared/components/memory_graph.js b/app/assets/javascripts/vue_shared/components/memory_graph.js index 643b77e04c7..f37ef1a5ca3 100644 --- a/app/assets/javascripts/vue_shared/components/memory_graph.js +++ b/app/assets/javascripts/vue_shared/components/memory_graph.js @@ -1,3 +1,5 @@ +import { getTimeago } from '../../lib/utils/datetime_utility'; + export default { name: 'MemoryGraph', props: { @@ -16,7 +18,7 @@ export default { }, computed: { getFormattedMedian() { - const deployedSince = gl.utils.getTimeago().format(this.deploymentTime * 1000); + const deployedSince = getTimeago().format(this.deploymentTime * 1000); return `Deployed ${deployedSince}`; }, }, diff --git a/app/assets/javascripts/vue_shared/components/popup_dialog.vue b/app/assets/javascripts/vue_shared/components/modal.vue index 6d15bbd84ba..55f466b7b41 100644 --- a/app/assets/javascripts/vue_shared/components/popup_dialog.vue +++ b/app/assets/javascripts/vue_shared/components/modal.vue @@ -1,6 +1,6 @@ <script> export default { - name: 'popup-dialog', + name: 'modal', props: { title: { @@ -75,7 +75,7 @@ export default { <template> <div class="modal-open"> <div - class="modal popup-dialog" + class="modal show" role="dialog" tabindex="-1" > diff --git a/app/assets/javascripts/vue_shared/components/project_avatar/image.vue b/app/assets/javascripts/vue_shared/components/project_avatar/image.vue new file mode 100644 index 00000000000..dce23bd65f6 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/project_avatar/image.vue @@ -0,0 +1,103 @@ +<script> + +/* This is a re-usable vue component for rendering a project avatar that + does not need to link to the project's profile. The image and an optional + tooltip can be configured by props passed to this component. + + Sample configuration: + + <project-avatar-image + :lazy="true" + :img-src="projectAvatarSrc" + :img-alt="tooltipText" + :tooltip-text="tooltipText" + tooltip-placement="top" + /> + +*/ + +import defaultAvatarUrl from 'images/no_avatar.png'; +import { placeholderImage } from '../../../lazy_loader'; +import tooltip from '../../directives/tooltip'; + +export default { + name: 'ProjectAvatarImage', + props: { + lazy: { + type: Boolean, + required: false, + default: false, + }, + imgSrc: { + type: String, + required: false, + default: defaultAvatarUrl, + }, + cssClasses: { + type: String, + required: false, + default: '', + }, + imgAlt: { + type: String, + required: false, + default: 'project avatar', + }, + size: { + type: Number, + required: false, + default: 20, + }, + tooltipText: { + type: String, + required: false, + default: '', + }, + tooltipPlacement: { + type: String, + required: false, + default: 'top', + }, + }, + directives: { + tooltip, + }, + computed: { + // API response sends null when gravatar is disabled and + // we provide an empty string when we use it inside project avatar link. + // In both cases we should render the defaultAvatarUrl + sanitizedSource() { + return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; + }, + resultantSrcAttribute() { + return this.lazy ? placeholderImage : this.sanitizedSource; + }, + tooltipContainer() { + return this.tooltipText ? 'body' : null; + }, + avatarSizeClass() { + return `s${this.size}`; + }, + }, +}; +</script> + +<template> + <img + v-tooltip + class="avatar" + :class="{ + lazy, + [avatarSizeClass]: true, + [cssClasses]: true + }" + :src="resultantSrcAttribute" + :width="size" + :height="size" + :alt="imgAlt" + :data-src="sanitizedSource" + :data-container="tooltipContainer" + :data-placement="tooltipPlacement" + :title="tooltipText" + /> +</template> diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_dialog.vue b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue index 3ec50f14eb4..8053c65d498 100644 --- a/app/assets/javascripts/vue_shared/components/recaptcha_dialog.vue +++ b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue @@ -1,8 +1,8 @@ <script> -import PopupDialog from './popup_dialog.vue'; +import modal from './modal.vue'; export default { - name: 'recaptcha-dialog', + name: 'recaptcha-modal', props: { html: { @@ -20,7 +20,7 @@ export default { }, components: { - PopupDialog, + modal, }, methods: { @@ -65,9 +65,9 @@ export default { </script> <template> -<popup-dialog +<modal kind="warning" - class="recaptcha-dialog js-recaptcha-dialog" + class="recaptcha-modal js-recaptcha-modal" :hide-footer="true" :title="__('Please solve the reCAPTCHA')" @toggle="close" @@ -81,5 +81,5 @@ export default { v-html="html" ></div> </div> -</popup-dialog> +</modal> </template> diff --git a/app/assets/javascripts/vue_shared/components/toggle_button.vue b/app/assets/javascripts/vue_shared/components/toggle_button.vue index ddc9ddbc3a3..4277d9281a0 100644 --- a/app/assets/javascripts/vue_shared/components/toggle_button.vue +++ b/app/assets/javascripts/vue_shared/components/toggle_button.vue @@ -1,6 +1,13 @@ <script> + import { s__ } from '../../locale'; + import icon from './icon.vue'; import loadingIcon from './loading_icon.vue'; + const ICON_ON = 'status_success_borderless'; + const ICON_OFF = 'status_failed_borderless'; + const LABEL_ON = s__('ToggleButton|Toggle Status: ON'); + const LABEL_OFF = s__('ToggleButton|Toggle Status: OFF'); + export default { props: { name: { @@ -22,19 +29,10 @@ required: false, default: false, }, - enabledText: { - type: String, - required: false, - default: 'Enabled', - }, - disabledText: { - type: String, - required: false, - default: 'Disabled', - }, }, components: { + icon, loadingIcon, }, @@ -43,6 +41,15 @@ event: 'change', }, + computed: { + toggleIcon() { + return this.value ? ICON_ON : ICON_OFF; + }, + ariaLabel() { + return this.value ? LABEL_ON : LABEL_OFF; + }, + }, + methods: { toggleFeature() { if (!this.disabledInput) this.$emit('change', !this.value); @@ -60,10 +67,8 @@ /> <button type="button" - aria-label="Toggle" class="project-feature-toggle" - :data-enabled-text="enabledText" - :data-disabled-text="disabledText" + :aria-label="ariaLabel" :class="{ 'is-checked': value, 'is-disabled': disabledInput, @@ -72,6 +77,11 @@ @click="toggleFeature" > <loadingIcon class="loading-icon" /> + <span class="toggle-icon"> + <icon + css-classes="toggle-icon-svg" + :name="toggleIcon"/> + </span> </button> </label> </template> diff --git a/app/assets/javascripts/vue_shared/mixins/recaptcha_dialog_implementor.js b/app/assets/javascripts/vue_shared/mixins/recaptcha_modal_implementor.js index ef70f9432e3..ff1f565e79a 100644 --- a/app/assets/javascripts/vue_shared/mixins/recaptcha_dialog_implementor.js +++ b/app/assets/javascripts/vue_shared/mixins/recaptcha_modal_implementor.js @@ -1,4 +1,4 @@ -import RecaptchaDialog from '../components/recaptcha_dialog.vue'; +import recaptchaModal from '../components/recaptcha_modal.vue'; export default { data() { @@ -9,7 +9,7 @@ export default { }, components: { - RecaptchaDialog, + recaptchaModal, }, methods: { diff --git a/app/assets/javascripts/vue_shared/mixins/timeago.js b/app/assets/javascripts/vue_shared/mixins/timeago.js index 20f63ab663c..4e3b9d7b767 100644 --- a/app/assets/javascripts/vue_shared/mixins/timeago.js +++ b/app/assets/javascripts/vue_shared/mixins/timeago.js @@ -1,4 +1,4 @@ -import '../../lib/utils/datetime_utility'; +import { formatDate, getTimeago } from '../../lib/utils/datetime_utility'; /** * Mixin with time ago methods used in some vue components @@ -6,13 +6,13 @@ import '../../lib/utils/datetime_utility'; export default { methods: { timeFormated(time) { - const timeago = gl.utils.getTimeago(); + const timeago = getTimeago(); return timeago.format(time); }, tooltipTitle(time) { - return gl.utils.formatDate(time); + return formatDate(time); }, }, }; diff --git a/app/assets/stylesheets/framework/contextual-sidebar.scss b/app/assets/stylesheets/framework/contextual-sidebar.scss index 26a2db99e0a..5da06b90113 100644 --- a/app/assets/stylesheets/framework/contextual-sidebar.scss +++ b/app/assets/stylesheets/framework/contextual-sidebar.scss @@ -9,12 +9,6 @@ padding-left: $contextual-sidebar-width; } - // Override position: absolute - .right-sidebar { - position: fixed; - height: calc(100% - #{$header-height}); - } - .issues-bulk-update.right-sidebar.right-sidebar-expanded .issuable-sidebar-header { padding: 10px 0 15px; } @@ -29,7 +23,6 @@ .context-header { position: relative; margin-right: 2px; - width: $contextual-sidebar-width; a { transition: padding $sidebar-transition-duration; @@ -320,13 +313,14 @@ transition: width $sidebar-transition-duration; position: fixed; bottom: 0; - padding: 16px; + padding: $gl-padding; background-color: $gray-light; border: 0; border-top: 2px solid $border-color; color: $gl-text-color-secondary; display: flex; align-items: center; + line-height: 1; svg { margin-right: 8px; diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 478269f3fcf..bc907a390d8 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -16,27 +16,18 @@ @mixin set-visible { transform: translateY(0); - visibility: visible; - opacity: 1; - transition-duration: 100ms, 150ms, 25ms; - transition-delay: 35ms, 50ms, 25ms; + display: block; } @mixin set-invisible { transform: translateY(-10px); - visibility: hidden; - opacity: 0; - transition-property: opacity, transform, visibility; - transition-duration: 70ms, 250ms, 250ms; - transition-timing-function: linear, $dropdown-animation-timing; - transition-delay: 25ms, 50ms, 0ms; + display: none; } .open { .dropdown-menu, .dropdown-menu-nav { @include set-visible; - display: block; min-height: 40px; @media (max-width: $screen-xs-max) { @@ -55,6 +46,11 @@ } } +// Get search dropdown to line up with other nav dropdowns +.search-input-container .dropdown-menu { + margin-top: 11px; +} + .dropdown-toggle { padding: 6px 8px 6px 10px; background-color: $white-light; @@ -214,7 +210,6 @@ .dropdown-menu, .dropdown-menu-nav { @include set-invisible; - display: block; position: absolute; width: auto; top: 100%; diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 609f33582e1..1588036aeae 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -396,3 +396,8 @@ span.idiff { .file-fork-suggestion-note { margin-right: 1.5em; } + +.label-lfs { + color: $common-gray-light; + border: 1px solid $common-gray-light; +} diff --git a/app/assets/stylesheets/framework/issue_box.scss b/app/assets/stylesheets/framework/issue_box.scss index 1537b0744cc..1d8bd26cf1a 100644 --- a/app/assets/stylesheets/framework/issue_box.scss +++ b/app/assets/stylesheets/framework/issue_box.scss @@ -24,10 +24,14 @@ font-size: $gl-font-size; line-height: 25px; - &.status-box-closed { + &.status-box-mr-closed { background-color: $gl-danger; } + &.status-box-issue-closed { + background-color: $gl-primary; + } + &.status-box-merged { background-color: $gl-primary; } diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 5389eb0a5f2..6b07ffdbd61 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -74,7 +74,7 @@ } .md-header-tab { - @media(max-width: $screen-xs-max) { + @media (max-width: $screen-xs-max) { flex: 1; width: 100%; border-bottom: 1px solid $border-color; @@ -82,16 +82,23 @@ } } -.md-header-toolbar { - margin-left: auto; +.nav-links { + li.md-header-toolbar { + margin-left: auto; + display: none; - @media(max-width: $screen-xs-max) { - flex: none; - display: flex; - justify-content: center; - width: 100%; - padding-top: $gl-padding-top; - padding-bottom: $gl-padding-top; + &.active { + display: block; + + @media (max-width: $screen-xs-max) { + flex: none; + display: flex; + justify-content: center; + width: 100%; + padding-top: $gl-padding-top; + padding-bottom: $gl-padding-top; + } + } } } @@ -175,7 +182,7 @@ margin-left: $gl-padding; margin-right: -5px; - @media(max-width: $screen-xs-max) { + @media (max-width: $screen-xs-max) { margin-left: 0; margin-right: 0; } @@ -239,7 +246,7 @@ } } -@media(max-width: $screen-xs-max) { +@media (max-width: $screen-xs-max) { .atwho-view-ul { width: 350px; } diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss index 600a1f53b58..a12f28efce6 100644 --- a/app/assets/stylesheets/framework/mobile.scss +++ b/app/assets/stylesheets/framework/mobile.scss @@ -111,21 +111,4 @@ aside:not(.right-sidebar) { display: none; } - - .show-aside { - display: block !important; - } -} - -.show-aside { - display: none; - position: fixed; - right: 0; - top: 30%; - padding: 5px 15px; - background: $show-aside-bg; - font-size: 20px; - color: $show-aside-color; - z-index: 100; - box-shadow: 0 1px 2px $show-aside-shadow; } diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index ce551e6b7ce..1be66d0ab21 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -44,11 +44,18 @@ body.modal-open { } } -.modal.popup-dialog { - display: block; +.modal { + background-color: $black-transparent; + z-index: 2100; + + @media (min-width: $screen-md-min) { + .modal-dialog { + margin: 30px auto; + } + } } -.recaptcha-dialog .recaptcha-form { +.recaptcha-modal .recaptcha-form { display: inline-block; .recaptcha { diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 0742c0a2a09..d61809cb0a4 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -90,11 +90,6 @@ .right-sidebar { border-left: 1px solid $border-color; height: calc(100% - #{$header-height}); - - &.affix { - position: fixed; - top: $header-height; - } } .with-performance-bar .right-sidebar.affix { diff --git a/app/assets/stylesheets/framework/toggle.scss b/app/assets/stylesheets/framework/toggle.scss index 71765da3908..0cd83df218f 100644 --- a/app/assets/stylesheets/framework/toggle.scss +++ b/app/assets/stylesheets/framework/toggle.scss @@ -27,7 +27,7 @@ border: 0; outline: 0; display: block; - width: 100px; + width: 50px; height: 24px; cursor: pointer; user-select: none; @@ -42,31 +42,31 @@ background: none; } - &::before { - color: $feature-toggle-text-color; - font-size: 12px; - line-height: 24px; - position: absolute; - top: 0; - left: 25px; - right: 5px; - text-align: center; - overflow: hidden; - text-overflow: ellipsis; - animation: animate-disabled .2s ease-in; - content: attr(data-disabled-text); - } - - &::after { + .toggle-icon { position: relative; display: block; - content: ""; - width: 22px; - height: 18px; left: 0; border-radius: 9px; background: $feature-toggle-color; transition: all .2s ease; + + &, + .toggle-icon-svg { + width: 18px; + height: 18px; + } + + .toggle-icon-svg { + fill: $feature-toggle-color-disabled; + } + + .toggle-status-checked { + display: none; + } + + .toggle-status-unchecked { + display: inline; + } } .loading-icon { @@ -77,11 +77,10 @@ top: 50%; left: 50%; transform: translate(-50%, -50%); - } &.is-loading { - &::before { + .toggle-icon { display: none; } @@ -100,15 +99,20 @@ &.is-checked { background: $feature-toggle-color-enabled; - &::before { - left: 5px; - right: 25px; - animation: animate-enabled .2s ease-in; - content: attr(data-enabled-text); - } + .toggle-icon { + left: calc(100% - 18px); - &::after { - left: calc(100% - 22px); + .toggle-icon-svg { + fill: $feature-toggle-color-enabled; + } + + .toggle-status-checked { + display: inline; + } + + .toggle-status-unchecked { + display: none; + } } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 5de5403916f..1d6c7a5c472 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -219,6 +219,7 @@ $gl-input-padding: 10px; $gl-vert-padding: 6px; $gl-padding-top: 10px; $gl-sidebar-padding: 22px; +$gl-bar-padding: 3px; /* * Misc @@ -245,9 +246,6 @@ $btn-sm-side-margin: 7px; $btn-xs-side-margin: 5px; $issue-status-expired: $orange-500; $issuable-sidebar-color: $gl-text-color-secondary; -$show-aside-bg: #eee; -$show-aside-color: #777; -$show-aside-shadow: #ddd; $group-path-color: #999; $namespace-kind-color: #aaa; $panel-heading-link-color: #777; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 8f8f11e3857..e1637618ab2 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -122,7 +122,7 @@ } .right-sidebar { - position: absolute; + position: fixed; top: $header-height; bottom: 0; right: 0; @@ -502,7 +502,7 @@ top: $header-height + $performance-bar-height; .issuable-sidebar { - height: calc(100% - #{$header-height} - #{$performance-bar-height}); + height: calc(100% - #{$performance-bar-height}); } } @@ -610,11 +610,19 @@ } .issuable-status-box { - float: none; - display: inline-block; + align-self: stretch; + display: flex; + justify-content: center; + align-items: center; margin-top: 0; - height: auto; - align-self: center; + padding-left: 9px; + padding-right: 9px; + + @media (min-width: $screen-sm-min) { + display: inline-block; + height: auto; + align-self: center; + } } .issuable-gutter-toggle { diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 402412eae71..da3c2d7fa5d 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -1,16 +1,3 @@ -.modal.popup-dialog { - display: block; - background-color: $black-transparent; - z-index: 2100; - - @media (min-width: $screen-md-min) { - .modal-dialog { - width: 600px; - margin: 30px auto; - } - } -} - .project-refs-form, .project-refs-target-form { display: inline-block; @@ -35,9 +22,10 @@ } } -.multi-file { +.ide-view { display: flex; - height: calc(100vh - 145px); + height: calc(100vh - #{$header-height}); + color: $almost-black; border-top: 1px solid $white-dark; border-bottom: 1px solid $white-dark; @@ -48,12 +36,47 @@ } } +.with-performance-bar .ide-view { + height: calc(100vh - #{$header-height}); +} + .ide-file-list { flex: 1; - overflow: scroll; .file { cursor: pointer; + + &.file-open { + background: $white-normal; + } + + .repo-file-name { + white-space: nowrap; + text-overflow: ellipsis; + } + + .unsaved-icon { + color: $indigo-700; + float: right; + font-size: smaller; + line-height: 20px; + } + + .repo-new-btn { + display: none; + margin-top: -4px; + margin-bottom: -4px; + } + + &:hover { + .repo-new-btn { + display: block; + } + + .unsaved-icon { + display: none; + } + } } a { @@ -68,10 +91,9 @@ .multi-file-table-name, .multi-file-table-col-commit-message { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + overflow: visible; max-width: 0; + padding: 6px 12px; } .multi-file-table-name { @@ -79,6 +101,7 @@ } .multi-file-table-col-commit-message { + white-space: nowrap; width: 50%; } @@ -92,7 +115,7 @@ .multi-file-tabs { display: flex; - overflow: scroll; + overflow-x: auto; background-color: $white-normal; box-shadow: inset 0 -1px $white-dark; @@ -141,9 +164,38 @@ height: 0; } +.blob-editor-container { + flex: 1; + height: 0; + display: flex; + flex-direction: column; + justify-content: center; + + .vertical-center { + min-height: auto; + } +} + +.multi-file-editor-holder { + height: 100%; +} + .multi-file-editor-btn-group { - padding: $grid-size; + padding: $gl-bar-padding $gl-padding; border-top: 1px solid $white-dark; + border-bottom: 1px solid $white-dark; + background: $white-light; +} + +.ide-status-bar { + padding: $gl-bar-padding $gl-padding; + background: $white-light; + display: flex; + justify-content: space-between; + + svg { + vertical-align: middle; + } } // Not great, but this is to deal with our current output @@ -151,10 +203,6 @@ height: 100%; overflow: scroll; - .blob-viewer { - height: 100%; - } - .file-content.code { display: flex; @@ -175,18 +223,101 @@ } } +.file-content.blob-no-preview { + a { + margin-left: auto; + margin-right: auto; + } +} + .multi-file-commit-panel { display: flex; flex-direction: column; height: 100%; width: 290px; - padding: $gl-padding; + padding: 0; background-color: $gray-light; border-left: 1px solid $white-dark; + .projects-sidebar { + display: flex; + flex-direction: column; + } + + .multi-file-commit-panel-inner { + display: flex; + flex: 1; + flex-direction: column; + } + + .multi-file-commit-panel-inner-scroll { + display: flex; + flex: 1; + flex-direction: column; + overflow: auto; + } + &.is-collapsed { width: 60px; - padding: 0; + + .multi-file-commit-list { + padding-top: $gl-padding; + overflow: hidden; + } + + .multi-file-context-bar-icon { + align-items: center; + + svg { + float: none; + margin: 0; + } + } + } + + .branch-container { + border-left: 4px solid $indigo-700; + margin-bottom: $gl-bar-padding; + } + + .branch-header { + background: $white-dark; + display: flex; + } + + .branch-header-title { + flex: 1; + padding: $grid-size $gl-padding; + color: $indigo-700; + font-weight: $gl-font-weight-bold; + + svg { + vertical-align: middle; + } + } + + .branch-header-btns { + padding: $gl-vert-padding $gl-padding; + } + + .left-collapse-btn { + display: none; + background: $gray-light; + text-align: left; + border-top: 1px solid $white-dark; + + svg { + vertical-align: middle; + } + } +} + +.multi-file-context-bar-icon { + padding: 10px; + + svg { + margin-right: 10px; + float: left; } } @@ -199,9 +330,9 @@ .multi-file-commit-panel-header { display: flex; align-items: center; - padding: 0 0 12px; margin-bottom: 12px; border-bottom: 1px solid $white-dark; + padding: $gl-btn-padding 0; &.is-collapsed { border-bottom: 1px solid $white-dark; @@ -210,23 +341,33 @@ margin-left: auto; margin-right: auto; } + + .multi-file-commit-panel-collapse-btn { + margin-right: auto; + margin-left: auto; + border-left: 0; + } } } -.multi-file-commit-panel-collapse-btn { - padding-top: 0; - padding-bottom: 0; - margin-left: auto; - font-size: 20px; +.multi-file-commit-panel-header-title { + display: flex; + flex: 1; + padding: $gl-btn-padding; - &.is-collapsed { - margin-right: auto; + svg { + margin-right: $gl-btn-padding; } } +.multi-file-commit-panel-collapse-btn { + border-left: 1px solid $white-dark; +} + .multi-file-commit-list { flex: 1; - overflow: scroll; + overflow: auto; + padding: $gl-padding; } .multi-file-commit-list-item { @@ -257,7 +398,7 @@ } .multi-file-commit-form { - padding-top: 12px; + padding: $gl-padding; border-top: 1px solid $white-dark; } @@ -308,3 +449,40 @@ } } } + +.ide-loading { + display: flex; + height: 100vh; + align-items: center; + justify-content: center; +} + +.ide-empty-state { + display: flex; + height: 100vh; + align-items: center; + justify-content: center; +} + +.repo-new-btn { + .dropdown-toggle svg { + margin-top: -2px; + margin-bottom: 2px; + } + + .dropdown-menu { + left: auto; + right: 0; + + label { + font-weight: $gl-font-weight-normal; + padding: 5px 8px; + margin-bottom: 0; + } + } +} + +.ide-flash-container.flash-container { + margin-top: $header-height; + margin-bottom: 0; +} diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 49c8e546bf2..c9363188505 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -108,13 +108,6 @@ input[type="checkbox"]:hover { // Custom dropdown positioning .dropdown-menu { - transition-property: opacity, transform; - transition-duration: 250ms, 250ms; - transition-delay: 0ms, 25ms; - transition-timing-function: $dropdown-animation-timing; - transform: translateY(0); - opacity: 0; - display: block; left: -5px; } @@ -152,13 +145,6 @@ input[type="checkbox"]:hover { background-color: $nav-badge-bg; border-color: $border-color; } - - .dropdown-menu { - transition-duration: 100ms, 75ms; - transition-delay: 75ms, 100ms; - transform: translateY(7px); - opacity: 1; - } } &.has-value { diff --git a/app/assets/stylesheets/pages/stat_graph.scss b/app/assets/stylesheets/pages/stat_graph.scss index cede147d559..8e2c42c1bd3 100644 --- a/app/assets/stylesheets/pages/stat_graph.scss +++ b/app/assets/stylesheets/pages/stat_graph.scss @@ -10,7 +10,6 @@ } .axis { - fill: $stat-graph-axis-fill; font-size: 10px; } @@ -54,9 +53,7 @@ } .selection rect { - fill: $stat-graph-selection-fill; fill-opacity: 0.1; - stroke: $stat-graph-selection-stroke; stroke-width: 1px; stroke-opacity: 0.4; shape-rendering: crispedges; diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index cde1e284d2d..86bade49ec9 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -8,12 +8,12 @@ class AutocompleteController < ApplicationController def users @users = AutocompleteUsersFinder.new(params: params, current_user: current_user, project: @project, group: @group).execute - render json: @users, only: [:name, :username, :id], methods: [:avatar_url] + render json: UserSerializer.new.represent(@users) end def user @user = User.find(params[:id]) - render json: @user, only: [:name, :username, :id], methods: [:avatar_url] + render json: UserSerializer.new.represent(@user) end def projects diff --git a/app/controllers/concerns/boards_responses.rb b/app/controllers/concerns/boards_responses.rb index 2c9c095a5d7..a145049dc7d 100644 --- a/app/controllers/concerns/boards_responses.rb +++ b/app/controllers/concerns/boards_responses.rb @@ -24,11 +24,11 @@ module BoardsResponses end def respond_with_boards - respond_with(@boards) + respond_with(@boards) # rubocop:disable Gitlab/ModuleWithInstanceVariables end def respond_with_board - respond_with(@board) + respond_with(@board) # rubocop:disable Gitlab/ModuleWithInstanceVariables end def respond_with(resource) diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb index 782f0be9c4a..6f4fdcdaa4f 100644 --- a/app/controllers/concerns/creates_commit.rb +++ b/app/controllers/concerns/creates_commit.rb @@ -1,6 +1,8 @@ module CreatesCommit extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize + # rubocop:disable Gitlab/ModuleWithInstanceVariables def create_commit(service, success_path:, failure_path:, failure_view: nil, success_notice: nil) if can?(current_user, :push_code, @project) @project_to_commit_into = @project @@ -45,6 +47,7 @@ module CreatesCommit end end end + # rubocop:enable Gitlab/ModuleWithInstanceVariables def authorize_edit_tree! return if can_collaborate_with_project? @@ -77,6 +80,7 @@ module CreatesCommit end end + # rubocop:disable Gitlab/ModuleWithInstanceVariables def new_merge_request_path project_new_merge_request_path( @project_to_commit_into, @@ -88,20 +92,28 @@ module CreatesCommit } ) end + # rubocop:enable Gitlab/ModuleWithInstanceVariables def existing_merge_request_path - project_merge_request_path(@project, @merge_request) + project_merge_request_path(@project, @merge_request) # rubocop:disable Gitlab/ModuleWithInstanceVariables end + # rubocop:disable Gitlab/ModuleWithInstanceVariables def merge_request_exists? - return @merge_request if defined?(@merge_request) - - @merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened - .find_by(source_project_id: @project_to_commit_into, source_branch: @branch_name, target_branch: @start_branch) + strong_memoize(:merge_request) do + MergeRequestsFinder.new(current_user, project_id: @project.id) + .execute + .opened + .find_by( + source_project_id: @project_to_commit_into, + source_branch: @branch_name, + target_branch: @start_branch) + end end + # rubocop:enable Gitlab/ModuleWithInstanceVariables def different_project? - @project_to_commit_into != @project + @project_to_commit_into != @project # rubocop:disable Gitlab/ModuleWithInstanceVariables end def create_merge_request? @@ -109,6 +121,6 @@ module CreatesCommit # as the target branch in the same project, # we don't want to create a merge request. params[:create_merge_request].present? && - (different_project? || @start_branch != @branch_name) + (different_project? || @start_branch != @branch_name) # rubocop:disable Gitlab/ModuleWithInstanceVariables end end diff --git a/app/controllers/concerns/group_tree.rb b/app/controllers/concerns/group_tree.rb index 9d4f97aa443..b10147835f3 100644 --- a/app/controllers/concerns/group_tree.rb +++ b/app/controllers/concerns/group_tree.rb @@ -1,4 +1,5 @@ module GroupTree + # rubocop:disable Gitlab/ModuleWithInstanceVariables def render_group_tree(groups) @groups = if params[:filter].present? Gitlab::GroupHierarchy.new(groups.search(params[:filter])) @@ -20,5 +21,6 @@ module GroupTree render json: serializer.represent(@groups) end end + # rubocop:enable Gitlab/ModuleWithInstanceVariables end end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 281756af57a..74a4f437dc8 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -17,7 +17,7 @@ module IssuableActions end def update - @issuable = update_service.execute(issuable) + @issuable = update_service.execute(issuable) # rubocop:disable Gitlab/ModuleWithInstanceVariables respond_to do |format| format.html do @@ -55,7 +55,6 @@ module IssuableActions def destroy Issuable::DestroyService.new(issuable.project, current_user).execute(issuable) - TodoService.new.destroy_issuable(issuable, current_user) name = issuable.human_class_name flash[:notice] = "The #{name} was successfully deleted." @@ -81,7 +80,7 @@ module IssuableActions private def recaptcha_check_if_spammable(should_redirect = true, &block) - return yield unless @issuable.is_a? Spammable + return yield unless issuable.is_a? Spammable recaptcha_check_with_fallback(should_redirect, &block) end @@ -89,7 +88,7 @@ module IssuableActions def render_conflict_response respond_to do |format| format.html do - @conflict = true + @conflict = true # rubocop:disable Gitlab/ModuleWithInstanceVariables render :edit end @@ -104,7 +103,7 @@ module IssuableActions end def labels - @labels ||= LabelsFinder.new(current_user, project_id: @project.id).execute + @labels ||= LabelsFinder.new(current_user, project_id: @project.id).execute # rubocop:disable Gitlab/ModuleWithInstanceVariables end def authorize_destroy_issuable! @@ -114,7 +113,7 @@ module IssuableActions end def authorize_admin_issuable! - unless can?(current_user, :"admin_#{resource_name}", @project) + unless can?(current_user, :"admin_#{resource_name}", @project) # rubocop:disable Gitlab/ModuleWithInstanceVariables return access_denied! end end @@ -148,6 +147,7 @@ module IssuableActions @resource_name ||= controller_name.singularize end + # rubocop:disable Gitlab/ModuleWithInstanceVariables def render_entity_json if @issuable.valid? render json: serializer.represent(@issuable) @@ -155,6 +155,7 @@ module IssuableActions render json: { errors: @issuable.errors.full_messages }, status: :unprocessable_entity end end + # rubocop:enable Gitlab/ModuleWithInstanceVariables def serializer raise NotImplementedError @@ -165,6 +166,6 @@ module IssuableActions end def parent - @project || @group + @project || @group # rubocop:disable Gitlab/ModuleWithInstanceVariables end end diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index f3c9251225f..b25e753a5ad 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -2,6 +2,7 @@ module IssuableCollections extend ActiveSupport::Concern include SortingHelper include Gitlab::IssuableMetadata + include Gitlab::Utils::StrongMemoize included do helper_method :finder @@ -9,6 +10,7 @@ module IssuableCollections private + # rubocop:disable Gitlab/ModuleWithInstanceVariables def set_issuables_index @issuables = issuables_collection @issuables = @issuables.page(params[:page]) @@ -33,6 +35,7 @@ module IssuableCollections @users.push(author) if author end end + # rubocop:enable Gitlab/ModuleWithInstanceVariables def issuables_collection finder.execute.preload(preload_for_collection) @@ -41,7 +44,7 @@ module IssuableCollections def redirect_out_of_range(total_pages) return false if total_pages.zero? - out_of_range = @issuables.current_page > total_pages + out_of_range = @issuables.current_page > total_pages # rubocop:disable Gitlab/ModuleWithInstanceVariables if out_of_range redirect_to(url_for(params.merge(page: total_pages, only_path: true))) @@ -51,7 +54,7 @@ module IssuableCollections end def issuable_page_count - page_count_for_relation(@issuables, finder.row_count) + page_count_for_relation(@issuables, finder.row_count) # rubocop:disable Gitlab/ModuleWithInstanceVariables end def page_count_for_relation(relation, row_count) @@ -66,6 +69,7 @@ module IssuableCollections finder_class.new(current_user, filter_params) end + # rubocop:disable Gitlab/ModuleWithInstanceVariables def filter_params set_sort_order_from_cookie set_default_state @@ -90,6 +94,7 @@ module IssuableCollections @filter_params.permit(IssuableFinder::VALID_PARAMS) end + # rubocop:enable Gitlab/ModuleWithInstanceVariables def set_default_state params[:state] = 'opened' if params[:state].blank? @@ -129,9 +134,9 @@ module IssuableCollections end def finder - return @finder if defined?(@finder) - - @finder = issuable_finder_for(@finder_type) + strong_memoize(:finder) do + issuable_finder_for(@finder_type) # rubocop:disable Gitlab/ModuleWithInstanceVariables + end end def collection_type diff --git a/app/controllers/concerns/issues_action.rb b/app/controllers/concerns/issues_action.rb index ad594903331..d4cccbe6442 100644 --- a/app/controllers/concerns/issues_action.rb +++ b/app/controllers/concerns/issues_action.rb @@ -2,6 +2,7 @@ module IssuesAction extend ActiveSupport::Concern include IssuableCollections + # rubocop:disable Gitlab/ModuleWithInstanceVariables def issues @finder_type = IssuesFinder @label = finder.labels.first @@ -17,4 +18,5 @@ module IssuesAction format.atom { render layout: 'xml.atom' } end end + # rubocop:enable Gitlab/ModuleWithInstanceVariables end diff --git a/app/controllers/concerns/merge_requests_action.rb b/app/controllers/concerns/merge_requests_action.rb index 8b569a01afd..4d44df3bba9 100644 --- a/app/controllers/concerns/merge_requests_action.rb +++ b/app/controllers/concerns/merge_requests_action.rb @@ -2,6 +2,7 @@ module MergeRequestsAction extend ActiveSupport::Concern include IssuableCollections + # rubocop:disable Gitlab/ModuleWithInstanceVariables def merge_requests @finder_type = MergeRequestsFinder @label = finder.labels.first @@ -10,6 +11,7 @@ module MergeRequestsAction @issuable_meta_data = issuable_meta_data(@merge_requests, collection_type) end + # rubocop:enable Gitlab/ModuleWithInstanceVariables private diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb index 081f3336780..d92cf8b4894 100644 --- a/app/controllers/concerns/milestone_actions.rb +++ b/app/controllers/concerns/milestone_actions.rb @@ -6,7 +6,7 @@ module MilestoneActions format.html { redirect_to milestone_redirect_path } format.json do render json: tabs_json("shared/milestones/_merge_requests_tab", { - merge_requests: @milestone.sorted_merge_requests, + merge_requests: @milestone.sorted_merge_requests, # rubocop:disable Gitlab/ModuleWithInstanceVariables show_project_name: true }) end @@ -18,7 +18,7 @@ module MilestoneActions format.html { redirect_to milestone_redirect_path } format.json do render json: tabs_json("shared/milestones/_participants_tab", { - users: @milestone.participants + users: @milestone.participants # rubocop:disable Gitlab/ModuleWithInstanceVariables }) end end @@ -29,7 +29,7 @@ module MilestoneActions format.html { redirect_to milestone_redirect_path } format.json do render json: tabs_json("shared/milestones/_labels_tab", { - labels: @milestone.labels + labels: @milestone.labels # rubocop:disable Gitlab/ModuleWithInstanceVariables }) end end @@ -43,6 +43,7 @@ module MilestoneActions } end + # rubocop:disable Gitlab/ModuleWithInstanceVariables def milestone_redirect_path if @project project_milestone_path(@project, @milestone) @@ -52,4 +53,5 @@ module MilestoneActions dashboard_milestone_path(@milestone.safe_title, title: @milestone.title) end end + # rubocop:enable Gitlab/ModuleWithInstanceVariables end diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index be2e1b47feb..e82a5650935 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -1,5 +1,6 @@ module NotesActions include RendersNotes + include Gitlab::Utils::StrongMemoize extend ActiveSupport::Concern included do @@ -30,6 +31,7 @@ module NotesActions render json: notes_json end + # rubocop:disable Gitlab/ModuleWithInstanceVariables def create create_params = note_params.merge( merge_request_diff_head_sha: params[:merge_request_diff_head_sha], @@ -47,7 +49,9 @@ module NotesActions format.html { redirect_back_or_default } end end + # rubocop:enable Gitlab/ModuleWithInstanceVariables + # rubocop:disable Gitlab/ModuleWithInstanceVariables def update @note = Notes::UpdateService.new(project, current_user, note_params).execute(note) @@ -60,6 +64,7 @@ module NotesActions format.html { redirect_back_or_default } end end + # rubocop:enable Gitlab/ModuleWithInstanceVariables def destroy if note.editable? @@ -138,7 +143,7 @@ module NotesActions end else template = "discussions/_diff_discussion" - @fresh_discussion = true + @fresh_discussion = true # rubocop:disable Gitlab/ModuleWithInstanceVariables locals = { discussions: [discussion], on_image: on_image } end @@ -191,7 +196,7 @@ module NotesActions end def noteable - @noteable ||= notes_finder.target || @note&.noteable + @noteable ||= notes_finder.target || @note&.noteable # rubocop:disable Gitlab/ModuleWithInstanceVariables end def require_noteable! @@ -211,20 +216,21 @@ module NotesActions end def note_project - return @note_project if defined?(@note_project) - return nil unless project + strong_memoize(:note_project) do + return nil unless project - note_project_id = params[:note_project_id] + note_project_id = params[:note_project_id] - @note_project = - if note_project_id.present? - Project.find(note_project_id) - else - project - end + the_project = + if note_project_id.present? + Project.find(note_project_id) + else + project + end - return access_denied! unless can?(current_user, :create_note, @note_project) + return access_denied! unless can?(current_user, :create_note, the_project) - @note_project + the_project + end end end diff --git a/app/controllers/concerns/oauth_applications.rb b/app/controllers/concerns/oauth_applications.rb index 9849aa93fa6..f0a68f23566 100644 --- a/app/controllers/concerns/oauth_applications.rb +++ b/app/controllers/concerns/oauth_applications.rb @@ -14,6 +14,6 @@ module OauthApplications end def load_scopes - @scopes = Doorkeeper.configuration.scopes + @scopes ||= Doorkeeper.configuration.scopes end end diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb index e9b9e9b38bc..90bb7a87b45 100644 --- a/app/controllers/concerns/preview_markdown.rb +++ b/app/controllers/concerns/preview_markdown.rb @@ -1,6 +1,7 @@ module PreviewMarkdown extend ActiveSupport::Concern + # rubocop:disable Gitlab/ModuleWithInstanceVariables def preview_markdown result = PreviewMarkdownService.new(@project, current_user, params).execute @@ -20,4 +21,5 @@ module PreviewMarkdown } } end + # rubocop:enable Gitlab/ModuleWithInstanceVariables end diff --git a/app/controllers/concerns/renders_commits.rb b/app/controllers/concerns/renders_commits.rb index bb2c1dfa00a..fb41dc1e8a8 100644 --- a/app/controllers/concerns/renders_commits.rb +++ b/app/controllers/concerns/renders_commits.rb @@ -1,6 +1,6 @@ module RendersCommits def prepare_commits_for_rendering(commits) - Banzai::CommitRenderer.render(commits, @project, current_user) + Banzai::CommitRenderer.render(commits, @project, current_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables commits end diff --git a/app/controllers/concerns/renders_notes.rb b/app/controllers/concerns/renders_notes.rb index 824ad06465c..e7ef297879f 100644 --- a/app/controllers/concerns/renders_notes.rb +++ b/app/controllers/concerns/renders_notes.rb @@ -1,4 +1,5 @@ module RendersNotes + # rubocop:disable Gitlab/ModuleWithInstanceVariables def prepare_notes_for_rendering(notes, noteable = nil) preload_noteable_for_regular_notes(notes) preload_max_access_for_authors(notes, @project) @@ -7,6 +8,7 @@ module RendersNotes notes end + # rubocop:enable Gitlab/ModuleWithInstanceVariables private diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb index be2e6c7f193..3d61458c064 100644 --- a/app/controllers/concerns/service_params.rb +++ b/app/controllers/concerns/service_params.rb @@ -66,7 +66,7 @@ module ServiceParams FILTER_BLANK_PARAMS = [:password].freeze def service_params - dynamic_params = @service.event_channel_names + @service.event_names + dynamic_params = @service.event_channel_names + @service.event_names # rubocop:disable Gitlab/ModuleWithInstanceVariables service_params = params.permit(:id, service: ALLOWED_PARAMS_CE + dynamic_params) if service_params[:service].is_a?(Hash) diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb index ffea712a833..9095cc7f783 100644 --- a/app/controllers/concerns/snippets_actions.rb +++ b/app/controllers/concerns/snippets_actions.rb @@ -4,6 +4,7 @@ module SnippetsActions def edit end + # rubocop:disable Gitlab/ModuleWithInstanceVariables def raw disposition = params[:inline] == 'false' ? 'attachment' : 'inline' @@ -14,6 +15,7 @@ module SnippetsActions filename: @snippet.sanitized_file_name ) end + # rubocop:enable Gitlab/ModuleWithInstanceVariables private diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb index 03d8e188093..922aa58a00f 100644 --- a/app/controllers/concerns/spammable_actions.rb +++ b/app/controllers/concerns/spammable_actions.rb @@ -2,6 +2,7 @@ module SpammableActions extend ActiveSupport::Concern include Recaptcha::Verify + include Gitlab::Utils::StrongMemoize included do before_action :authorize_submit_spammable!, only: :mark_as_spam @@ -18,9 +19,9 @@ module SpammableActions private def ensure_spam_config_loaded! - return @spam_config_loaded if defined?(@spam_config_loaded) - - @spam_config_loaded = Gitlab::Recaptcha.load_configurations! + strong_memoize(:spam_config_loaded) do + Gitlab::Recaptcha.load_configurations! + end end def recaptcha_check_with_fallback(should_redirect = true, &fallback) diff --git a/app/controllers/concerns/toggle_subscription_action.rb b/app/controllers/concerns/toggle_subscription_action.rb index 92cb534343e..776583579e8 100644 --- a/app/controllers/concerns/toggle_subscription_action.rb +++ b/app/controllers/concerns/toggle_subscription_action.rb @@ -12,7 +12,7 @@ module ToggleSubscriptionAction private def subscribable_project - @project || raise(NotImplementedError) + @project ||= raise(NotImplementedError) end def subscribable_resource diff --git a/app/controllers/concerns/with_performance_bar.rb b/app/controllers/concerns/with_performance_bar.rb index ed253042701..230bbe4b1aa 100644 --- a/app/controllers/concerns/with_performance_bar.rb +++ b/app/controllers/concerns/with_performance_bar.rb @@ -6,6 +6,7 @@ module WithPerformanceBar end def peek_enabled? + return true if Rails.env.development? return false unless Gitlab::PerformanceBar.enabled?(current_user) if RequestStore.active? diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb new file mode 100644 index 00000000000..1ff25a45398 --- /dev/null +++ b/app/controllers/ide_controller.rb @@ -0,0 +1,6 @@ +class IdeController < ApplicationController + layout 'nav_only' + + def index + end +end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 770381472c5..d838b8dc29e 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -5,9 +5,6 @@ class Projects::BlobController < Projects::ApplicationController include RendersBlob include ActionView::Helpers::SanitizeHelper - # Raised when given an invalid file path - InvalidPathError = Class.new(StandardError) - prepend_before_action :authenticate_user!, only: [:edit] before_action :require_non_empty_project, except: [:new, :create] @@ -61,7 +58,6 @@ class Projects::BlobController < Projects::ApplicationController create_commit(Files::UpdateService, success_path: -> { after_edit_path }, failure_view: :edit, failure_path: project_blob_path(@project, @id)) - rescue Files::UpdateService::FileChangedError @conflict = true render :edit @@ -132,7 +128,6 @@ class Projects::BlobController < Projects::ApplicationController def assign_blob_vars @id = params[:id] @ref, @path = extract_ref(@id) - rescue InvalidPathError render_404 end diff --git a/app/controllers/projects/clusters/gcp_controller.rb b/app/controllers/projects/clusters/gcp_controller.rb index c965a055fdd..66a851c52c7 100644 --- a/app/controllers/projects/clusters/gcp_controller.rb +++ b/app/controllers/projects/clusters/gcp_controller.rb @@ -65,6 +65,7 @@ class Projects::Clusters::GcpController < Projects::ApplicationController params.require(:cluster).permit( :enabled, :name, + :environment_scope, provider_gcp_attributes: [ :gcp_project_id, :zone, diff --git a/app/controllers/projects/clusters/user_controller.rb b/app/controllers/projects/clusters/user_controller.rb index d7678512073..d0db64b2fa9 100644 --- a/app/controllers/projects/clusters/user_controller.rb +++ b/app/controllers/projects/clusters/user_controller.rb @@ -26,6 +26,7 @@ class Projects::Clusters::UserController < Projects::ApplicationController params.require(:cluster).permit( :enabled, :name, + :environment_scope, platform_kubernetes_attributes: [ :namespace, :api_url, diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb index 4a7879db313..1dc7f1b3a7f 100644 --- a/app/controllers/projects/clusters_controller.rb +++ b/app/controllers/projects/clusters_controller.rb @@ -87,6 +87,7 @@ class Projects::ClustersController < Projects::ApplicationController if cluster.managed? params.require(:cluster).permit( :enabled, + :environment_scope, platform_kubernetes_attributes: [ :namespace ] @@ -95,6 +96,7 @@ class Projects::ClustersController < Projects::ApplicationController params.require(:cluster).permit( :enabled, :name, + :environment_scope, platform_kubernetes_attributes: [ :api_url, :token, diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 1c4c09c772f..4865ec3dfe5 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -110,7 +110,7 @@ class Projects::JobsController < Projects::ApplicationController def erase if @build.erase(erased_by: current_user) redirect_to project_job_path(project, @build), - notice: "Build has been successfully erased!" + notice: "Job has been successfully erased!" else respond_422 end diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index 1511fc08c89..dc524b790a0 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -9,7 +9,10 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap before_action :build_merge_request, except: [:create] def new - define_new_vars + # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/40934 + Gitlab::GitalyClient.allow_n_plus_1_calls do + define_new_vars + end end def create diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index e7b3b73024b..6b59c8461a3 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -131,7 +131,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo .new(project, current_user, wip_event: 'unwip') .execute(@merge_request) - render json: serializer.represent(@merge_request) + render json: serialize_widget(@merge_request) end def commit_change_content @@ -147,7 +147,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo .new(@project, current_user) .cancel(@merge_request) - render json: serializer.represent(@merge_request) + render json: serialize_widget(@merge_request) end def merge @@ -304,6 +304,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end end + def serialize_widget(merge_request) + serializer.represent(merge_request, serializer: 'widget') + end + def serializer MergeRequestSerializer.new(current_user: current_user, project: merge_request.project) end diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index 627cb2bd93c..5940fae8dd0 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -11,7 +11,7 @@ class Projects::NotesController < Projects::ApplicationController # Controller actions are returned from AbstractController::Base and methods of parent classes are # excluded in order to return only specific controller related methods. # That is ok for the app (no :create method in ancestors) - # but fails for tests because there is a :create method on FactoryGirl (one of the ancestors) + # but fails for tests because there is a :create method on FactoryBot (one of the ancestors) # # see https://github.com/rails/rails/blob/v4.2.7/actionpack/lib/abstract_controller/base.rb#L78 # diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index ec7c645df5a..b478e7b5e05 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -1,9 +1,11 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController before_action :schedule, except: [:index, :new, :create] + before_action :play_rate_limit, only: [:play] + before_action :authorize_play_pipeline_schedule!, only: [:play] before_action :authorize_read_pipeline_schedule! before_action :authorize_create_pipeline_schedule!, only: [:new, :create] - before_action :authorize_update_pipeline_schedule!, except: [:index, :new, :create] + before_action :authorize_update_pipeline_schedule!, except: [:index, :new, :create, :play] before_action :authorize_admin_pipeline_schedule!, only: [:destroy] def index @@ -40,6 +42,18 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController end end + def play + job_id = RunPipelineScheduleWorker.perform_async(schedule.id, current_user.id) + + if job_id + flash[:notice] = "Successfully scheduled a pipeline to run. Go to the <a href=\"#{project_pipelines_path(@project)}\">Pipelines page</a> for details.".html_safe + else + flash[:alert] = 'Unable to schedule a pipeline to run immediately' + end + + redirect_to pipeline_schedules_path(@project) + end + def take_ownership if schedule.update(owner: current_user) redirect_to pipeline_schedules_path(@project) @@ -60,6 +74,17 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController private + def play_rate_limit + return unless current_user + + limiter = ::Gitlab::ActionRateLimiter.new(action: :play_pipeline_schedule) + + return unless limiter.throttled?([current_user, schedule], 1) + + flash[:alert] = 'You cannot play this scheduled pipeline at the moment. Please wait a minute.' + redirect_to pipeline_schedules_path(@project) + end + def schedule @schedule ||= project.pipeline_schedules.find(params[:id]) end @@ -70,6 +95,10 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController variables_attributes: [:id, :key, :value, :_destroy] ) end + def authorize_play_pipeline_schedule! + return access_denied! unless can?(current_user, :play_pipeline_schedule, schedule) + end + def authorize_update_pipeline_schedule! return access_denied! unless can?(current_user, :update_pipeline_schedule, schedule) end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 7ad7b3003af..e146d0d3cd5 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -29,6 +29,8 @@ class Projects::PipelinesController < Projects::ApplicationController @pipelines_count = PipelinesFinder .new(project).execute.count + @pipelines.map(&:commit) # List commits for batch loading + respond_to do |format| format.html format.json do diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index f3719059f88..f752a46f828 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -26,6 +26,7 @@ class Projects::TreeController < Projects::ApplicationController respond_to do |format| format.html do + lfs_blob_ids @last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 8e9d6766d80..6f609348402 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -9,6 +9,7 @@ class ProjectsController < Projects::ApplicationController before_action :repository, except: [:index, :new, :create] before_action :assign_ref_vars, only: [:show], if: :repo_exists? before_action :tree, only: [:show], if: [:repo_exists?, :project_view_files?] + before_action :lfs_blob_ids, only: [:show], if: [:repo_exists?, :project_view_files?] before_action :project_export_enabled, only: [:export, :download_export, :remove_export, :generate_new_export] # Authorize diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 4754a67450f..d13407a06c8 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -306,7 +306,7 @@ module ApplicationHelper cookies["sidebar_collapsed"] == "true" end - def show_new_repo? + def show_new_ide? cookies["new_repo"] == "true" && body_data_page != 'projects:show' end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 556ed233ccf..3c2ee2cb5bc 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -8,7 +8,7 @@ module BlobHelper %w(credits changelog news copying copyright license authors) end - def edit_path(project = @project, ref = @ref, path = @path, options = {}) + def edit_blob_path(project = @project, ref = @ref, path = @path, options = {}) project_edit_blob_path(project, tree_join(ref, path), options[:link_opts]) @@ -26,10 +26,10 @@ module BlobHelper button_tag 'Edit', class: "#{common_classes} disabled has-tooltip", title: "You can only edit files when you are on a branch", data: { container: 'body' } # This condition applies to anonymous or users who can edit directly elsif !current_user || (current_user && can_modify_blob?(blob, project, ref)) - link_to 'Edit', edit_path(project, ref, path, options), class: "#{common_classes} btn-sm" + link_to 'Edit', edit_blob_path(project, ref, path, options), class: "#{common_classes} btn-sm" elsif current_user && can?(current_user, :fork_project, project) continue_params = { - to: edit_path(project, ref, path, options), + to: edit_blob_path(project, ref, path, options), notice: edit_in_new_fork_notice, notice_now: edit_in_new_fork_notice_now } @@ -41,6 +41,43 @@ module BlobHelper end end + def ide_edit_path(project = @project, ref = @ref, path = @path, options = {}) + "#{ide_path}/project#{edit_blob_path(project, ref, path, options)}" + end + + def ide_edit_text + "#{_('Multi Edit')} <span class='label label-primary'>#{_('Beta')}</span>".html_safe + end + + def ide_blob_link(project = @project, ref = @ref, path = @path, options = {}) + return unless show_new_ide? + + blob = options.delete(:blob) + blob ||= project.repository.blob_at(ref, path) rescue nil + + return unless blob && blob.readable_text? + + common_classes = "btn js-edit-ide #{options[:extra_class]}" + + if !on_top_of_branch?(project, ref) + button_tag ide_edit_text, class: "#{common_classes} disabled has-tooltip", title: _('You can only edit files when you are on a branch'), data: { container: 'body' } + # This condition applies to anonymous or users who can edit directly + elsif current_user && can_modify_blob?(blob, project, ref) + link_to ide_edit_text, ide_edit_path(project, ref, path, options), class: "#{common_classes} btn-sm" + elsif current_user && can?(current_user, :fork_project, project) + continue_params = { + to: ide_edit_path(project, ref, path, options), + notice: edit_in_new_fork_notice, + notice_now: edit_in_new_fork_notice_now + } + fork_path = project_forks_path(project, namespace_key: current_user.namespace.id, continue: continue_params) + + button_tag ide_edit_text, + class: common_classes, + data: { fork_path: fork_path } + end + end + def modify_file_link(project = @project, ref = @ref, path = @path, label:, action:, btn_class:, modal_type:) return unless current_user diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb new file mode 100644 index 00000000000..7e4eb06b99d --- /dev/null +++ b/app/helpers/clusters_helper.rb @@ -0,0 +1,5 @@ +module ClustersHelper + def has_multiple_clusters?(project) + false + end +end diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index b5dece38de1..e26ce6da030 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -35,7 +35,7 @@ module FormHelper multi_select: true, 'input-meta': 'name', 'always-show-selectbox': true, - current_user_info: current_user.to_json(only: [:id, :name]) + current_user_info: UserSerializer.new.represent(current_user) } } end diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index a77aa0ad2cc..7f3c118c7ab 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -182,6 +182,11 @@ module GitlabRoutingHelper edit_project_pipeline_schedule_path(project, schedule) end + def play_pipeline_schedule_path(schedule, *args) + project = schedule.project + play_project_pipeline_schedule_path(project, schedule, *args) + end + def take_ownership_pipeline_schedule_path(schedule, *args) project = schedule.project take_ownership_project_pipeline_schedule_path(project, schedule, *args) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 4c60f4b0cd0..2668cf78afe 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -32,7 +32,7 @@ module IssuablesHelper end end - def serialize_issuable(issuable) + def serialize_issuable(issuable, serializer: nil) serializer_klass = case issuable when Issue IssueSerializer @@ -42,7 +42,7 @@ module IssuablesHelper serializer_klass .new(current_user: current_user, project: issuable.project) - .represent(issuable) + .represent(issuable, serializer: serializer) .to_json end @@ -362,7 +362,7 @@ module IssuablesHelper moveIssueEndpoint: move_namespace_project_issue_path(namespace_id: issuable.project.namespace.to_param, project_id: issuable.project, id: issuable), projectsAutocompleteEndpoint: autocomplete_projects_path(project_id: @project.id), editable: can_edit_issuable, - currentUser: current_user.as_json(only: [:username, :id, :name], methods: :avatar_url), + currentUser: UserSerializer.new.represent(current_user), rootPath: root_path, fullPath: @project.full_path } diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 212cdbb8157..0f110bd25c5 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -74,7 +74,7 @@ module IssuesHelper elsif item.try(:merged?) 'status-box-merged' elsif item.closed? - 'status-box-closed' + 'status-box-mr-closed' elsif item.try(:upcoming?) 'status-box-upcoming' else diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 2f57660516d..0f9ac958f95 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -139,7 +139,7 @@ module SearchHelper id: "filtered-search-#{type}", placeholder: 'Search or filter results...', data: { - 'username-params' => @users.to_json(only: [:id, :username]) + 'username-params' => UserSerializer.new.represent(@users) }, autocomplete: 'off' } diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index b05eb93b465..36a311dfa8a 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -43,14 +43,20 @@ module SortingHelper end def groups_sort_options_hash - options = { + { + sort_value_name => sort_title_name, + sort_value_name_desc => sort_title_name_desc, sort_value_recently_created => sort_title_recently_created, sort_value_oldest_created => sort_title_oldest_created, sort_value_recently_updated => sort_title_recently_updated, sort_value_oldest_updated => sort_title_oldest_updated } + end - options + def admin_groups_sort_options_hash + groups_sort_options_hash.merge( + sort_value_largest_group => sort_title_largest_group + ) end def member_sort_options_hash diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb index 77a82b895ce..50e17fe7717 100644 --- a/app/mailers/emails/notes.rb +++ b/app/mailers/emails/notes.rb @@ -5,7 +5,7 @@ module Emails @commit = @note.noteable @target_url = project_commit_url(*note_target_url_options) - mail_answer_thread(@commit, note_thread_options(recipient_id)) + mail_answer_note_thread(@commit, @note, note_thread_options(recipient_id)) end def note_issue_email(recipient_id, note_id) @@ -13,7 +13,7 @@ module Emails @issue = @note.noteable @target_url = project_issue_url(*note_target_url_options) - mail_answer_thread(@issue, note_thread_options(recipient_id)) + mail_answer_note_thread(@issue, @note, note_thread_options(recipient_id)) end def note_merge_request_email(recipient_id, note_id) @@ -21,7 +21,7 @@ module Emails @merge_request = @note.noteable @target_url = project_merge_request_url(*note_target_url_options) - mail_answer_thread(@merge_request, note_thread_options(recipient_id)) + mail_answer_note_thread(@merge_request, @note, note_thread_options(recipient_id)) end def note_snippet_email(recipient_id, note_id) @@ -29,7 +29,7 @@ module Emails @snippet = @note.noteable @target_url = project_snippet_url(*note_target_url_options) - mail_answer_thread(@snippet, note_thread_options(recipient_id)) + mail_answer_note_thread(@snippet, @note, note_thread_options(recipient_id)) end def note_personal_snippet_email(recipient_id, note_id) @@ -37,7 +37,7 @@ module Emails @snippet = @note.noteable @target_url = snippet_url(@note.noteable) - mail_answer_thread(@snippet, note_thread_options(recipient_id)) + mail_answer_note_thread(@snippet, @note, note_thread_options(recipient_id)) end private diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 9efabe3f44e..ec886e993c3 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -119,8 +119,8 @@ class Notify < BaseMailer headers['Reply-To'] = address fallback_reply_message_id = "<reply-#{reply_key}@#{Gitlab.config.gitlab.host}>".freeze - headers['References'] ||= '' - headers['References'] << ' ' << fallback_reply_message_id + headers['References'] ||= [] + headers['References'] << fallback_reply_message_id @reply_by_email = true end @@ -156,6 +156,18 @@ class Notify < BaseMailer mail_thread(model, headers) end + def mail_answer_note_thread(model, note, headers = {}) + headers['Message-ID'] = message_id(note) + headers['In-Reply-To'] = message_id(note.references.last) + headers['References'] = note.references.map { |ref| message_id(ref) } + + headers['X-GitLab-Discussion-ID'] = note.discussion.id if note.part_of_discussion? + + headers[:subject]&.prepend('Re: ') + + mail_thread(model, headers) + end + def reply_key @reply_key ||= SentNotification.reply_key end diff --git a/app/models/blob.rb b/app/models/blob.rb index 29e762724e3..19ad110db58 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -77,9 +77,15 @@ class Blob < SimpleDelegator end def self.lazy(project, commit_id, path) - BatchLoader.for(commit_id: commit_id, path: path).batch do |items, loader| - project.repository.blobs_at(items.map(&:values)).each do |blob| - loader.call({ commit_id: blob.commit_id, path: blob.path }, blob) if blob + BatchLoader.for({ project: project, commit_id: commit_id, path: path }).batch do |items, loader| + items_by_project = items.group_by { |i| i[:project] } + + items_by_project.each do |project, items| + items = items.map { |i| i.values_at(:commit_id, :path) } + + project.repository.blobs_at(items).each do |blob| + loader.call({ project: blob.project, commit_id: blob.commit_id, path: blob.path }, blob) if blob + end end end end diff --git a/app/models/blob_viewer/dependency_manager.rb b/app/models/blob_viewer/dependency_manager.rb index a8d9be945dc..cc4950240af 100644 --- a/app/models/blob_viewer/dependency_manager.rb +++ b/app/models/blob_viewer/dependency_manager.rb @@ -27,10 +27,17 @@ module BlobViewer private - def package_name_from_json(key) - prepare! + def json_data + @json_data ||= begin + prepare! + JSON.parse(blob.data) + rescue + {} + end + end - JSON.parse(blob.data)[key] rescue nil + def package_name_from_json(key) + json_data[key] end def package_name_from_method_call(name) diff --git a/app/models/blob_viewer/package_json.rb b/app/models/blob_viewer/package_json.rb index 09221efb56c..46cd2f04f4d 100644 --- a/app/models/blob_viewer/package_json.rb +++ b/app/models/blob_viewer/package_json.rb @@ -16,7 +16,25 @@ module BlobViewer @package_name ||= package_name_from_json('name') end + def package_type + private? ? 'private package' : super + end + def package_url + private? ? homepage : npm_url + end + + private + + def private? + !!json_data['private'] + end + + def homepage + json_data['homepage'] + end + + def npm_url "https://www.npmjs.com/package/#{package_name}" end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 85960f1b6bb..83fe23606d1 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -491,7 +491,6 @@ module Ci end def valid_dependency? - return false unless complete? return false if artifacts_expired? return false if erased? diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index eebbf7c4218..d4690da3be6 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -228,6 +228,10 @@ module Ci statuses.select(:stage).distinct.count end + def total_size + statuses.count(:id) + end + def stages_names statuses.order(:stage_idx).distinct .pluck(:stage, :stage_idx).map(&:first) @@ -283,8 +287,12 @@ module Ci Ci::Pipeline.truncate_sha(sha) end + # NOTE: This is loaded lazily and will never be nil, even if the commit + # cannot be found. + # + # Use constructs like: `pipeline.commit.present?` def commit - @commit ||= project.commit_by(oid: sha) + @commit ||= Commit.lazy(project, sha) end def branch? @@ -334,12 +342,9 @@ module Ci end def latest? - return false unless ref - - commit = project.commit(ref) - return false unless commit + return false unless ref && commit.present? - commit.sha == sha + project.commit(ref) == commit end def retried diff --git a/app/models/commit.rb b/app/models/commit.rb index 307e4fcedfe..2be07ca7d3c 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -52,6 +52,20 @@ class Commit diffs.reduce(0) { |sum, d| sum + Gitlab::Git::Util.count_lines(d.diff) } end + def order_by(collection:, order_by:, sort:) + return collection unless %w[email name commits].include?(order_by) + return collection unless %w[asc desc].include?(sort) + + collection.sort do |a, b| + operands = [a, b].tap { |o| o.reverse! if sort == 'desc' } + + attr1, attr2 = operands.first.public_send(order_by), operands.second.public_send(order_by) # rubocop:disable PublicSend + + # use case insensitive comparison for string values + order_by.in?(%w[email name]) ? attr1.casecmp(attr2) : attr1 <=> attr2 + end + end + # Truncate sha to 8 characters def truncate_sha(sha) sha[0..MIN_SHA_LENGTH] @@ -72,6 +86,20 @@ class Commit def valid_hash?(key) !!(/\A#{COMMIT_SHA_PATTERN}\z/ =~ key) end + + def lazy(project, oid) + BatchLoader.for({ project: project, oid: oid }).batch do |items, loader| + items_by_project = items.group_by { |i| i[:project] } + + items_by_project.each do |project, commit_ids| + oids = commit_ids.map { |i| i[:oid] } + + project.repository.commits_by(oids: oids).each do |commit| + loader.call({ project: commit.project, oid: commit.id }, commit) if commit + end + end + end + end end attr_accessor :raw @@ -89,7 +117,7 @@ class Commit end def ==(other) - (self.class === other) && (raw == other.raw) + other.is_a?(self.class) && raw == other.raw end def self.reference_prefix @@ -210,8 +238,8 @@ class Commit notes.includes(:author) end - def method_missing(m, *args, &block) - @raw.__send__(m, *args, &block) # rubocop:disable GitlabSecurity/PublicSend + def method_missing(method, *args, &block) + @raw.__send__(method, *args, &block) # rubocop:disable GitlabSecurity/PublicSend end def respond_to_missing?(method, include_private = false) diff --git a/app/models/concerns/blocks_json_serialization.rb b/app/models/concerns/blocks_json_serialization.rb new file mode 100644 index 00000000000..8019e6adc1c --- /dev/null +++ b/app/models/concerns/blocks_json_serialization.rb @@ -0,0 +1,16 @@ +# Overrides `as_json` and `to_json` to raise an exception when called in order +# to prevent accidentally exposing attributes +# +# Not that that would ever happen... but just in case. +module BlocksJsonSerialization + extend ActiveSupport::Concern + + JsonSerializationError = Class.new(StandardError) + + def to_json(*) + raise JsonSerializationError, + "JSON serialization has been disabled on #{self.class.name}" + end + + alias_method :as_json, :to_json +end diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index b43eaeaeea0..c013e5a708f 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -44,13 +44,11 @@ module Mentionable end def all_references(current_user = nil, extractor: nil) - @extractors ||= {} - # Use custom extractor if it's passed in the function parameters. if extractor - @extractors[current_user] = extractor + extractors[current_user] = extractor else - extractor = @extractors[current_user] ||= Gitlab::ReferenceExtractor.new(project, current_user) + extractor = extractors[current_user] ||= Gitlab::ReferenceExtractor.new(project, current_user) extractor.reset_memoized_values end @@ -69,6 +67,10 @@ module Mentionable extractor end + def extractors + @extractors ||= {} + end + def mentioned_users(current_user = nil) all_references(current_user).users end diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index 7026f565706..fd6703831e4 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -103,9 +103,11 @@ module Milestoneish end def memoize_per_user(user, method_name) - @memoized ||= {} - @memoized[method_name] ||= {} - @memoized[method_name][user&.id] ||= yield + memoized_users[method_name][user&.id] ||= yield + end + + def memoized_users + @memoized_users ||= Hash.new { |h, k| h[k] = {} } end # override in a class that includes this module to get a faster query diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index 5d75b2aa6a3..86f28f30032 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -46,6 +46,7 @@ module Noteable notes.inc_relations_for_view.grouped_diff_discussions(*args) end + # rubocop:disable Gitlab/ModuleWithInstanceVariables def resolvable_discussions @resolvable_discussions ||= if defined?(@discussions) @@ -54,6 +55,7 @@ module Noteable discussion_notes.resolvable.discussions(self) end end + # rubocop:enable Gitlab/ModuleWithInstanceVariables def discussions_resolvable? resolvable_discussions.any?(&:resolvable?) diff --git a/app/models/concerns/participable.rb b/app/models/concerns/participable.rb index ce69fd34ac5..e48bc0be410 100644 --- a/app/models/concerns/participable.rb +++ b/app/models/concerns/participable.rb @@ -56,15 +56,17 @@ module Participable # # Returns an Array of User instances. def participants(current_user = nil) - @participants ||= Hash.new do |hash, user| - hash[user] = raw_participants(user) - end - - @participants[current_user] + all_participants[current_user] end private + def all_participants + @all_participants ||= Hash.new do |hash, user| + hash[user] = raw_participants(user) + end + end + def raw_participants(current_user = nil) current_user ||= author ext = Gitlab::ReferenceExtractor.new(project, current_user) diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb index e961c97e337..835f26aa57b 100644 --- a/app/models/concerns/relative_positioning.rb +++ b/app/models/concerns/relative_positioning.rb @@ -52,7 +52,7 @@ module RelativePositioning # to its predecessor. This process will recursively move all the predecessors until we have a place if (after.relative_position - before.relative_position) < 2 before.move_before - @positionable_neighbours = [before] + @positionable_neighbours = [before] # rubocop:disable Gitlab/ModuleWithInstanceVariables end self.relative_position = position_between(before.relative_position, after.relative_position) @@ -65,7 +65,7 @@ module RelativePositioning if before.shift_after? issue_to_move = self.class.in_projects(project_ids).find_by!(relative_position: pos_after) issue_to_move.move_after - @positionable_neighbours = [issue_to_move] + @positionable_neighbours = [issue_to_move] # rubocop:disable Gitlab/ModuleWithInstanceVariables pos_after = issue_to_move.relative_position end @@ -80,7 +80,7 @@ module RelativePositioning if after.shift_before? issue_to_move = self.class.in_projects(project_ids).find_by!(relative_position: pos_before) issue_to_move.move_before - @positionable_neighbours = [issue_to_move] + @positionable_neighbours = [issue_to_move] # rubocop:disable Gitlab/ModuleWithInstanceVariables pos_before = issue_to_move.relative_position end @@ -132,6 +132,7 @@ module RelativePositioning end end + # rubocop:disable Gitlab/ModuleWithInstanceVariables def save_positionable_neighbours return unless @positionable_neighbours @@ -140,4 +141,5 @@ module RelativePositioning status end + # rubocop:enable Gitlab/ModuleWithInstanceVariables end diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb index f006a271327..b6c7b6735b9 100644 --- a/app/models/concerns/resolvable_discussion.rb +++ b/app/models/concerns/resolvable_discussion.rb @@ -31,15 +31,11 @@ module ResolvableDiscussion end def resolvable? - return @resolvable if @resolvable.present? - - @resolvable = potentially_resolvable? && notes.any?(&:resolvable?) + @resolvable ||= potentially_resolvable? && notes.any?(&:resolvable?) end def resolved? - return @resolved if @resolved.present? - - @resolved = resolvable? && notes.none?(&:to_be_resolved?) + @resolved ||= resolvable? && notes.none?(&:to_be_resolved?) end def first_note @@ -49,13 +45,13 @@ module ResolvableDiscussion def first_note_to_resolve return unless resolvable? - @first_note_to_resolve ||= notes.find(&:to_be_resolved?) + @first_note_to_resolve ||= notes.find(&:to_be_resolved?) # rubocop:disable Gitlab/ModuleWithInstanceVariables end def last_resolved_note return unless resolved? - @last_resolved_note ||= resolved_notes.sort_by(&:resolved_at).last + @last_resolved_note ||= resolved_notes.sort_by(&:resolved_at).last # rubocop:disable Gitlab/ModuleWithInstanceVariables end def resolved_notes @@ -95,7 +91,7 @@ module ResolvableDiscussion yield(notes_relation) # Set the notes array to the updated notes - @notes = notes_relation.fresh.to_a + @notes = notes_relation.fresh.to_a # rubocop:disable Gitlab/ModuleWithInstanceVariables self.class.memoized_values.each do |var| instance_variable_set(:"@#{var}", nil) diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index 22fde2eb134..5c1cce98ad4 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -88,7 +88,7 @@ module Routable def full_name if route && route.name.present? - @full_name ||= route.name + @full_name ||= route.name # rubocop:disable Gitlab/ModuleWithInstanceVariables else update_route if persisted? @@ -112,7 +112,7 @@ module Routable def expires_full_path_cache RequestStore.delete(full_path_key) if RequestStore.active? - @full_path = nil + @full_path = nil # rubocop:disable Gitlab/ModuleWithInstanceVariables end def build_full_path @@ -127,7 +127,7 @@ module Routable def uncached_full_path if route && route.path.present? - @full_path ||= route.path + @full_path ||= route.path # rubocop:disable Gitlab/ModuleWithInstanceVariables else update_route if persisted? @@ -166,7 +166,7 @@ module Routable route || build_route(source: self) route.path = build_full_path route.name = build_full_name - @full_path = nil - @full_name = nil + @full_path = nil # rubocop:disable Gitlab/ModuleWithInstanceVariables + @full_name = nil # rubocop:disable Gitlab/ModuleWithInstanceVariables end end diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb index 731d9b9a745..5e4274619c4 100644 --- a/app/models/concerns/spammable.rb +++ b/app/models/concerns/spammable.rb @@ -12,6 +12,7 @@ module Spammable attr_accessor :spam attr_accessor :spam_log + alias_method :spam?, :spam after_validation :check_for_spam, on: [:create, :update] @@ -34,10 +35,6 @@ module Spammable end end - def spam? - @spam - end - def check_for_spam error_msg = if Gitlab::Recaptcha.enabled? "Your #{spammable_entity_type} has been recognized as spam. "\ diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb index 25e2d8ea24e..d07041c2fdf 100644 --- a/app/models/concerns/taskable.rb +++ b/app/models/concerns/taskable.rb @@ -39,7 +39,7 @@ module Taskable def task_list_items return [] if description.blank? - @task_list_items ||= Taskable.get_tasks(description) + @task_list_items ||= Taskable.get_tasks(description) # rubocop:disable Gitlab/ModuleWithInstanceVariables end def tasks diff --git a/app/models/concerns/time_trackable.rb b/app/models/concerns/time_trackable.rb index 9f403d96ed5..5911b56c34c 100644 --- a/app/models/concerns/time_trackable.rb +++ b/app/models/concerns/time_trackable.rb @@ -21,9 +21,10 @@ module TimeTrackable has_many :timelogs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent end + # rubocop:disable Gitlab/ModuleWithInstanceVariables def spend_time(options) @time_spent = options[:duration] - @time_spent_user = options[:user] + @time_spent_user = User.find(options[:user_id]) @spent_at = options[:spent_at] @original_total_time_spent = nil @@ -36,6 +37,7 @@ module TimeTrackable end end alias_method :spend_time=, :spend_time + # rubocop:enable Gitlab/ModuleWithInstanceVariables def total_time_spent timelogs.sum(:time_spent) @@ -52,9 +54,10 @@ module TimeTrackable private def reset_spent_time - timelogs.new(time_spent: total_time_spent * -1, user: @time_spent_user) + timelogs.new(time_spent: total_time_spent * -1, user: @time_spent_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables end + # rubocop:disable Gitlab/ModuleWithInstanceVariables def add_or_subtract_spent_time timelogs.new( time_spent: time_spent, @@ -62,16 +65,19 @@ module TimeTrackable spent_at: @spent_at ) end + # rubocop:enable Gitlab/ModuleWithInstanceVariables def check_negative_time_spent return if time_spent.nil? || time_spent == :reset - # we need to cache the total time spent so multiple calls to #valid? - # doesn't give a false error - @original_total_time_spent ||= total_time_spent - - if time_spent < 0 && (time_spent.abs > @original_total_time_spent) + if time_spent < 0 && (time_spent.abs > original_total_time_spent) errors.add(:time_spent, 'Time to subtract exceeds the total time spent') end end + + # we need to cache the total time spent so multiple calls to #valid? + # doesn't give a false error + def original_total_time_spent + @original_total_time_spent ||= total_time_spent + end end diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb index 4a65738214b..d67b16584a4 100644 --- a/app/models/diff_discussion.rb +++ b/app/models/diff_discussion.rb @@ -22,12 +22,9 @@ class DiffDiscussion < Discussion def merge_request_version_params return unless for_merge_request? - return {} if active? - if on_merge_request_commit? - { commit_id: commit_id } - else - noteable.version_params_for(position.diff_refs) + version_params.tap do |params| + params[:commit_id] = commit_id if on_merge_request_commit? end end @@ -37,4 +34,12 @@ class DiffDiscussion < Discussion position: position.to_json ) end + + private + + def version_params + return {} if active? + + noteable.version_params_for(position.diff_refs) + end end diff --git a/app/models/identity.rb b/app/models/identity.rb index ff811e19f8a..b3fa7d8176a 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -8,20 +8,30 @@ class Identity < ActiveRecord::Base validates :extern_uid, allow_blank: true, uniqueness: { scope: :provider, case_sensitive: false } validates :user_id, uniqueness: { scope: :provider } + before_save :ensure_normalized_extern_uid, if: :extern_uid_changed? + scope :with_provider, ->(provider) { where(provider: provider) } scope :with_extern_uid, ->(provider, extern_uid) do iwhere(extern_uid: normalize_uid(provider, extern_uid)).with_provider(provider) end def ldap? - provider.starts_with?('ldap') + Gitlab::OAuth::Provider.ldap_provider?(provider) end def self.normalize_uid(provider, uid) - if provider.to_s.starts_with?('ldap') + if Gitlab::OAuth::Provider.ldap_provider?(provider) Gitlab::LDAP::Person.normalize_dn(uid) else uid.to_s end end + + private + + def ensure_normalized_extern_uid + return if extern_uid.nil? + + self.extern_uid = Identity.normalize_uid(self.provider, self.extern_uid) + end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 26a3388602a..c39789b047d 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -53,8 +53,8 @@ class MergeRequest < ActiveRecord::Base serialize :merge_params, Hash # rubocop:disable Cop/ActiveRecordSerialize after_create :ensure_merge_request_diff, unless: :importing? - after_update :reload_diff_if_branch_changed after_update :clear_memoized_shas + after_update :reload_diff_if_branch_changed # When this attribute is true some MR validation is ignored # It allows us to close or modify broken merge requests @@ -85,6 +85,14 @@ class MergeRequest < ActiveRecord::Base transition locked: :opened end + before_transition any => :opened do |merge_request| + merge_request.merge_jid = nil + + merge_request.run_after_commit do + UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id) + end + end + state :opened state :closed state :merged @@ -879,11 +887,11 @@ class MergeRequest < ActiveRecord::Base def state_icon_name if merged? - "check" + "git-merge" elsif closed? - "times" + "close" else - "circle-o" + "issue-open-m" end end diff --git a/app/models/note.rb b/app/models/note.rb index 02f9fd61e49..184fbd5f5ae 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -360,6 +360,16 @@ class Note < ActiveRecord::Base end end + def references + refs = [noteable] + + if part_of_discussion? + refs += discussion.notes.take_while { |n| n.id < id } + end + + refs + end + def expire_etag_cache return unless noteable&.discussions_rendered_on_frontend? diff --git a/app/models/project.rb b/app/models/project.rb index 5183a216c53..3440c01b356 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1148,7 +1148,7 @@ class Project < ActiveRecord::Base def change_head(branch) if repository.branch_exists?(branch) repository.before_change_head - repository.write_ref('HEAD', "refs/heads/#{branch}", force: true) + repository.write_ref('HEAD', "refs/heads/#{branch}") repository.copy_gitattributes(branch) repository.after_change_head reload_default_branch diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 1c065e1ddbd..2be35b6ea9d 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -46,6 +46,8 @@ class JiraService < IssueTrackerService context_path: url.path, auth_type: :basic, read_timeout: 120, + use_cookies: true, + additional_cookies: ['OBBasicAuth=fromDialog'], use_ssl: url.scheme == 'https' } end diff --git a/app/models/repository.rb b/app/models/repository.rb index c0e31eca8da..a34f5e5439b 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -19,7 +19,6 @@ class Repository attr_accessor :full_path, :disk_path, :project, :is_wiki delegate :ref_name_for_sha, to: :raw_repository - delegate :write_ref, to: :raw_repository CreateTreeError = Class.new(StandardError) @@ -118,6 +117,18 @@ class Repository @commit_cache[oid] = find_commit(oid) end + def commits_by(oids:) + return [] unless oids.present? + + commits = Gitlab::Git::Commit.batch_by_oid(raw_repository, oids) + + if commits.present? + Commit.decorate(commits, @project) + else + [] + end + end + def commits(ref, path: nil, limit: nil, offset: nil, skip_merges: false, after: nil, before: nil) options = { repo: raw_repository, @@ -221,6 +232,12 @@ class Repository branch_names.include?(branch_name) end + def tag_exists?(tag_name) + return false unless raw_repository + + tag_names.include?(tag_name) + end + def ref_exists?(ref) !!raw_repository&.ref_exists?(ref) rescue ArgumentError @@ -238,10 +255,11 @@ class Repository # This will still fail if the file is corrupted (e.g. 0 bytes) begin - write_ref(keep_around_ref_name(sha), sha, force: true) - rescue Gitlab::Git::Repository::GitError => ex - # Necessary because https://gitlab.com/gitlab-org/gitlab-ce/issues/20156 - return true if ex.message =~ /Failed to create locked file/ && ex.message =~ /File exists/ + write_ref(keep_around_ref_name(sha), sha) + rescue Rugged::ReferenceError => ex + Rails.logger.error "Unable to create #{REF_KEEP_AROUND} reference for repository #{path}: #{ex}" + rescue Rugged::OSError => ex + raise unless ex.message =~ /Failed to create locked file/ && ex.message =~ /File exists/ Rails.logger.error "Unable to create #{REF_KEEP_AROUND} reference for repository #{path}: #{ex}" end @@ -251,6 +269,10 @@ class Repository ref_exists?(keep_around_ref_name(sha)) end + def write_ref(ref_path, sha) + rugged.references.create(ref_path, sha, force: true) + end + def diverging_commit_counts(branch) root_ref_hash = raw_repository.commit(root_ref).id cache.fetch(:"diverging_commit_counts_#{branch.name}") do @@ -686,7 +708,9 @@ class Repository def tags_sorted_by(value) case value - when 'name' + when 'name_asc' + VersionSorter.sort(tags) { |tag| tag.name } + when 'name_desc' VersionSorter.rsort(tags) { |tag| tag.name } when 'updated_desc' tags_sorted_by_committed_date.reverse @@ -697,10 +721,14 @@ class Repository end end - def contributors + # Params: + # + # order_by: name|email|commits + # sort: asc|desc default: 'asc' + def contributors(order_by: nil, sort: 'asc') commits = self.commits(nil, limit: 2000, offset: 0, skip_merges: true) - commits.group_by(&:author_email).map do |email, commits| + commits = commits.group_by(&:author_email).map do |email, commits| contributor = Gitlab::Contributor.new contributor.email = email @@ -714,6 +742,7 @@ class Repository contributor end + Commit.order_by(collection: commits, order_by: order_by, sort: sort) end def refs_contains_sha(ref_type, sha) @@ -927,7 +956,7 @@ class Repository def merge_base(first_commit_id, second_commit_id) first_commit_id = commit(first_commit_id).try(:id) || first_commit_id second_commit_id = commit(second_commit_id).try(:id) || second_commit_id - rugged.merge_base(first_commit_id, second_commit_id) + raw_repository.merge_base(first_commit_id, second_commit_id) rescue Rugged::ReferenceError nil end @@ -990,7 +1019,7 @@ class Repository end def create_ref(ref, ref_path) - write_ref(ref_path, ref) + raw_repository.write_ref(ref_path, ref) end def ls_files(ref) diff --git a/app/models/user.rb b/app/models/user.rb index 92b461ce3ed..b52f17cd6a8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -18,6 +18,7 @@ class User < ActiveRecord::Base include CreatedAtFilterable include IgnorableColumn include BulkMemberAccessLoad + include BlocksJsonSerialization DEFAULT_NOTIFICATION_LEVEL = :participating @@ -738,7 +739,7 @@ class User < ActiveRecord::Base def ldap_user? if identities.loaded? - identities.find { |identity| identity.provider.start_with?('ldap') && !identity.extern_uid.nil? } + identities.find { |identity| Gitlab::OAuth::Provider.ldap_provider?(identity.provider) && !identity.extern_uid.nil? } else identities.exists?(["provider LIKE ? AND extern_uid IS NOT NULL", "ldap%"]) end diff --git a/app/models/user_synced_attributes_metadata.rb b/app/models/user_synced_attributes_metadata.rb index 9f374304164..548b99b69d9 100644 --- a/app/models/user_synced_attributes_metadata.rb +++ b/app/models/user_synced_attributes_metadata.rb @@ -6,11 +6,11 @@ class UserSyncedAttributesMetadata < ActiveRecord::Base SYNCABLE_ATTRIBUTES = %i[name email location].freeze def read_only?(attribute) - Gitlab.config.omniauth.sync_profile_from_provider && synced?(attribute) + sync_profile_from_provider? && synced?(attribute) end def read_only_attributes - return [] unless Gitlab.config.omniauth.sync_profile_from_provider + return [] unless sync_profile_from_provider? SYNCABLE_ATTRIBUTES.select { |key| synced?(key) } end @@ -22,4 +22,10 @@ class UserSyncedAttributesMetadata < ActiveRecord::Base def set_attribute_synced(attribute, value) write_attribute("#{attribute}_synced", value) end + + private + + def sync_profile_from_provider? + Gitlab::OAuth::Provider.sync_profile_from_provider?(provider) + end end diff --git a/app/policies/ci/pipeline_policy.rb b/app/policies/ci/pipeline_policy.rb index 4e689a9efd5..6363c382ff8 100644 --- a/app/policies/ci/pipeline_policy.rb +++ b/app/policies/ci/pipeline_policy.rb @@ -2,16 +2,18 @@ module Ci class PipelinePolicy < BasePolicy delegate { @subject.project } - condition(:protected_ref) do - access = ::Gitlab::UserAccess.new(@user, project: @subject.project) + condition(:protected_ref) { ref_protected?(@user, @subject.project, @subject.tag?, @subject.ref) } - if @subject.tag? - !access.can_create_tag?(@subject.ref) + rule { protected_ref }.prevent :update_pipeline + + def ref_protected?(user, project, tag, ref) + access = ::Gitlab::UserAccess.new(user, project: project) + + if tag + !access.can_create_tag?(ref) else - !access.can_update_branch?(@subject.ref) + !access.can_update_branch?(ref) end end - - rule { protected_ref }.prevent :update_pipeline end end diff --git a/app/policies/ci/pipeline_schedule_policy.rb b/app/policies/ci/pipeline_schedule_policy.rb index 6b7598e1821..abcf536b2f7 100644 --- a/app/policies/ci/pipeline_schedule_policy.rb +++ b/app/policies/ci/pipeline_schedule_policy.rb @@ -2,13 +2,23 @@ module Ci class PipelineSchedulePolicy < PipelinePolicy alias_method :pipeline_schedule, :subject + condition(:protected_ref) do + ref_protected?(@user, @subject.project, @subject.project.repository.tag_exists?(@subject.ref), @subject.ref) + end + condition(:owner_of_schedule) do can?(:developer_access) && pipeline_schedule.owned_by?(@user) end + rule { can?(:developer_access) }.policy do + enable :play_pipeline_schedule + end + rule { can?(:master_access) | owner_of_schedule }.policy do enable :update_pipeline_schedule enable :admin_pipeline_schedule end + + rule { protected_ref }.prevent :play_pipeline_schedule end end diff --git a/app/serializers/concerns/with_pagination.rb b/app/serializers/concerns/with_pagination.rb index d29e22d6740..89631b73fcf 100644 --- a/app/serializers/concerns/with_pagination.rb +++ b/app/serializers/concerns/with_pagination.rb @@ -14,7 +14,7 @@ module WithPagination # we shouldn't try to paginate single resources def represent(resource, opts = {}) if paginated? && resource.respond_to?(:page) - super(@paginator.paginate(resource), opts) + super(paginator.paginate(resource), opts) else super(resource, opts) end diff --git a/app/serializers/issuable_entity.rb b/app/serializers/issuable_entity.rb index 3b5a4fd4f79..6f31fbd6b7c 100644 --- a/app/serializers/issuable_entity.rb +++ b/app/serializers/issuable_entity.rb @@ -3,14 +3,6 @@ class IssuableEntity < Grape::Entity expose :id expose :iid - expose :author_id expose :description - expose :lock_version - expose :milestone_id expose :title - expose :updated_by_id - expose :created_at - expose :updated_at - expose :milestone, using: API::Entities::Milestone - expose :labels, using: LabelEntity end diff --git a/app/serializers/issuable_sidebar_entity.rb b/app/serializers/issuable_sidebar_entity.rb index ff23d8bf0c7..29138c803df 100644 --- a/app/serializers/issuable_sidebar_entity.rb +++ b/app/serializers/issuable_sidebar_entity.rb @@ -1,4 +1,5 @@ class IssuableSidebarEntity < Grape::Entity + include TimeTrackableEntity include RequestAwareEntity expose :participants, using: ::API::Entities::UserBasic do |issuable| @@ -8,9 +9,4 @@ class IssuableSidebarEntity < Grape::Entity expose :subscribed do |issuable| issuable.subscribed?(request.current_user, issuable.project) end - - expose :time_estimate - expose :total_time_spent - expose :human_time_estimate - expose :human_total_time_spent end diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index 9d52b8d9752..0bdd4d7a272 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -2,7 +2,15 @@ class IssueEntity < IssuableEntity include TimeTrackableEntity expose :state + expose :milestone_id + expose :updated_by_id + expose :created_at + expose :updated_at expose :deleted_at + expose :milestone, using: API::Entities::Milestone + expose :labels, using: LabelEntity + expose :lock_version + expose :author_id expose :confidential expose :discussion_locked expose :assignees, using: API::Entities::UserBasic diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb index e9d98d8baca..caf193bdae3 100644 --- a/app/serializers/merge_request_serializer.rb +++ b/app/serializers/merge_request_serializer.rb @@ -1,14 +1,14 @@ class MergeRequestSerializer < BaseSerializer # This overrided method takes care of which entity should be used - # to serialize the `merge_request` based on `basic` key in `opts` param. + # to serialize the `merge_request` based on `serializer` key in `opts` param. # Hence, `entity` doesn't need to be declared on the class scope. def represent(merge_request, opts = {}) entity = case opts[:serializer] when 'basic', 'sidebar' MergeRequestBasicEntity - else - MergeRequestEntity + else # It's 'widget' + MergeRequestWidgetEntity end super(merge_request, opts, entity) diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_widget_entity.rb index eece9445dca..f8e59b2ffd7 100644 --- a/app/serializers/merge_request_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -1,8 +1,5 @@ -class MergeRequestEntity < IssuableEntity - include TimeTrackableEntity - +class MergeRequestWidgetEntity < IssuableEntity expose :state - expose :deleted_at expose :in_progress_merge_commit_sha expose :merge_commit_sha expose :merge_error diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 85db2760e23..c8b112132b3 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -81,7 +81,7 @@ module Ci end def related_merge_requests - MergeRequest.where(source_project: pipeline.project, source_branch: pipeline.ref) + MergeRequest.opened.where(source_project: pipeline.project, source_branch: pipeline.ref) end end end diff --git a/app/services/concerns/issues/resolve_discussions.rb b/app/services/concerns/issues/resolve_discussions.rb index 7d45b4aa26a..26eb274f4d5 100644 --- a/app/services/concerns/issues/resolve_discussions.rb +++ b/app/services/concerns/issues/resolve_discussions.rb @@ -1,24 +1,28 @@ module Issues module ResolveDiscussions + include Gitlab::Utils::StrongMemoize + attr_reader :merge_request_to_resolve_discussions_of_iid, :discussion_to_resolve_id + # rubocop:disable Gitlab/ModuleWithInstanceVariables def filter_resolve_discussion_params @merge_request_to_resolve_discussions_of_iid ||= params.delete(:merge_request_to_resolve_discussions_of) @discussion_to_resolve_id ||= params.delete(:discussion_to_resolve) end + # rubocop:enable Gitlab/ModuleWithInstanceVariables def merge_request_to_resolve_discussions_of - return @merge_request_to_resolve_discussions_of if defined?(@merge_request_to_resolve_discussions_of) - - @merge_request_to_resolve_discussions_of = MergeRequestsFinder.new(current_user, project_id: project.id) - .execute - .find_by(iid: merge_request_to_resolve_discussions_of_iid) + strong_memoize(:merge_request_to_resolve_discussions_of) do + MergeRequestsFinder.new(current_user, project_id: project.id) + .execute + .find_by(iid: merge_request_to_resolve_discussions_of_iid) + end end def discussions_to_resolve return [] unless merge_request_to_resolve_discussions_of - @discussions_to_resolve ||= + @discussions_to_resolve ||= # rubocop:disable Gitlab/ModuleWithInstanceVariables if discussion_to_resolve_id discussion_or_nil = merge_request_to_resolve_discussions_of .find_discussion(discussion_to_resolve_id) diff --git a/app/services/files/base_service.rb b/app/services/files/base_service.rb index 38231f66009..8d4b9f14780 100644 --- a/app/services/files/base_service.rb +++ b/app/services/files/base_service.rb @@ -1,11 +1,14 @@ module Files class BaseService < Commits::CreateService + FileChangedError = Class.new(StandardError) + def initialize(*args) super @author_email = params[:author_email] @author_name = params[:author_name] @commit_message = params[:commit_message] + @last_commit_sha = params[:last_commit_sha] @file_path = params[:file_path] @previous_path = params[:previous_path] @@ -13,5 +16,16 @@ module Files @file_content = params[:file_content] @file_content = Base64.decode64(@file_content) if params[:file_content_encoding] == 'base64' end + + def file_has_changed?(path, commit_id) + return false unless commit_id + + last_commit = Gitlab::Git::Commit + .last_for_path(@start_project.repository, @start_branch, path) + + return false unless last_commit + + last_commit.sha != commit_id + end end end diff --git a/app/services/files/delete_service.rb b/app/services/files/delete_service.rb index 7952e5c95d4..32a57484d4e 100644 --- a/app/services/files/delete_service.rb +++ b/app/services/files/delete_service.rb @@ -11,5 +11,15 @@ module Files start_project: @start_project, start_branch_name: @start_branch) end + + private + + def validate! + super + + if file_has_changed?(@file_path, @last_commit_sha) + raise FileChangedError, "You are attempting to delete a file that has been previously updated." + end + end end end diff --git a/app/services/files/multi_service.rb b/app/services/files/multi_service.rb index bfacc462847..98a3e83c130 100644 --- a/app/services/files/multi_service.rb +++ b/app/services/files/multi_service.rb @@ -1,5 +1,7 @@ module Files class MultiService < Files::BaseService + UPDATE_FILE_ACTIONS = %w(update move delete).freeze + def create_commit! repository.multi_action( user: current_user, @@ -20,6 +22,7 @@ module Files params[:actions].each do |action| validate_action!(action) + validate_file_status!(action) end end @@ -28,5 +31,15 @@ module Files raise_error("Unknown action '#{action[:action]}'") end end + + def validate_file_status!(action) + return unless UPDATE_FILE_ACTIONS.include?(action[:action]) + + file_path = action[:previous_path] || action[:file_path] + + if file_has_changed?(file_path, action[:last_commit_id]) + raise_error("The file has changed since you started editing it: #{file_path}") + end + end end end diff --git a/app/services/files/update_service.rb b/app/services/files/update_service.rb index bcca1386bed..1902d1cea72 100644 --- a/app/services/files/update_service.rb +++ b/app/services/files/update_service.rb @@ -1,13 +1,5 @@ module Files class UpdateService < Files::BaseService - FileChangedError = Class.new(StandardError) - - def initialize(*args) - super - - @last_commit_sha = params[:last_commit_sha] - end - def create_commit! repository.update_file(current_user, @file_path, @file_content, message: @commit_message, @@ -21,21 +13,10 @@ module Files private - def file_has_changed? - return false unless @last_commit_sha && last_commit - - @last_commit_sha != last_commit.sha - end - - def last_commit - @last_commit ||= Gitlab::Git::Commit - .last_for_path(@start_project.repository, @start_branch, @file_path) - end - def validate! super - if file_has_changed? + if file_has_changed?(@file_path, @last_commit_sha) raise FileChangedError, "You are attempting to update a file that has changed since you started editing it." end end diff --git a/app/services/issuable/destroy_service.rb b/app/services/issuable/destroy_service.rb index 0610b401213..7197a426a72 100644 --- a/app/services/issuable/destroy_service.rb +++ b/app/services/issuable/destroy_service.rb @@ -1,8 +1,10 @@ module Issuable class DestroyService < IssuableBaseService def execute(issuable) - if issuable.destroy - issuable.update_project_counter_caches + TodoService.new.destroy_target(issuable) do |issuable| + if issuable.destroy + issuable.update_project_counter_caches + end end end end diff --git a/app/services/notes/destroy_service.rb b/app/services/notes/destroy_service.rb index b819bd17039..fb78420d324 100644 --- a/app/services/notes/destroy_service.rb +++ b/app/services/notes/destroy_service.rb @@ -1,7 +1,9 @@ module Notes class DestroyService < BaseService def execute(note) - note.destroy + TodoService.new.destroy_target(note) do |note| + note.destroy + end end end end diff --git a/app/services/projects/unlink_fork_service.rb b/app/services/projects/unlink_fork_service.rb index c499f384426..842fe4e09c4 100644 --- a/app/services/projects/unlink_fork_service.rb +++ b/app/services/projects/unlink_fork_service.rb @@ -5,7 +5,7 @@ module Projects if fork_source = @project.fork_source fork_source.lfs_objects.find_each do |lfs_object| - lfs_object.projects << @project + lfs_object.projects << @project unless lfs_object.projects.include?(@project) end refresh_forks_count(fork_source) diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb index 06ac86cd5a9..669c1ba0a22 100644 --- a/app/services/quick_actions/interpret_service.rb +++ b/app/services/quick_actions/interpret_service.rb @@ -405,7 +405,7 @@ module QuickActions if time_spent @updates[:spend_time] = { duration: time_spent, - user: current_user, + user_id: current_user.id, spent_at: time_spent_date } end @@ -428,7 +428,7 @@ module QuickActions current_user.can?(:"admin_#{issuable.to_ability_name}", project) end command :remove_time_spent do - @updates[:spend_time] = { duration: :reset, user: current_user } + @updates[:spend_time] = { duration: :reset, user_id: current_user.id } end desc "Append the comment with #{SHRUG}" diff --git a/app/services/spam_check_service.rb b/app/services/spam_check_service.rb index 11030bee8f1..d4ade869777 100644 --- a/app/services/spam_check_service.rb +++ b/app/services/spam_check_service.rb @@ -7,16 +7,19 @@ # - params with :request # module SpamCheckService + # rubocop:disable Gitlab/ModuleWithInstanceVariables def filter_spam_check_params @request = params.delete(:request) @api = params.delete(:api) @recaptcha_verified = params.delete(:recaptcha_verified) @spam_log_id = params.delete(:spam_log_id) end + # rubocop:enable Gitlab/ModuleWithInstanceVariables # In order to be proceed to the spam check process, @spammable has to be # a dirty instance, which means it should be already assigned with the new # attribute values. + # rubocop:disable Gitlab/ModuleWithInstanceVariables def spam_check(spammable, user) spam_service = SpamService.new(spammable, @request) @@ -24,4 +27,5 @@ module SpamCheckService user.spam_logs.find_by(id: @spam_log_id)&.update!(recaptcha_verified: true) end end + # rubocop:enable Gitlab/ModuleWithInstanceVariables end diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 575853fd66b..c2ca404b179 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -31,12 +31,20 @@ class TodoService mark_pending_todos_as_done(issue, current_user) end - # When we destroy an issuable we should: + # When we destroy a todo target we should: # - # * refresh the todos count cache for the current user + # * refresh the todos count cache for all users with todos on the target # - def destroy_issuable(issuable, user) - user.update_todos_count_cache + # This needs to yield back to the caller to destroy the target, because it + # collects the todo users before the todos themselves are deleted, then + # updates the todo counts for those users. + # + def destroy_target(target) + todo_users = User.where(id: target.todos.pending.select(:user_id)).to_a + + yield target + + todo_users.each(&:update_todos_count_cache) end # When we reassign an issue we should: diff --git a/app/views/admin/groups/index.html.haml b/app/views/admin/groups/index.html.haml index 535251fef5e..25946ba6eaf 100644 --- a/app/views/admin/groups/index.html.haml +++ b/app/views/admin/groups/index.html.haml @@ -11,23 +11,7 @@ .search-field-holder = search_field_tag :name, project_name, class: "form-control search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: 'Search by name' = icon("search", class: "search-icon") - .dropdown - - toggle_text = @sort.present? ? sort_options_hash[@sort] : sort_title_recently_created - = dropdown_toggle(toggle_text, { toggle: 'dropdown' }) - %ul.dropdown-menu.dropdown-menu-align-right - %li.dropdown-header - Sort by - %li - = link_to admin_groups_path(sort: sort_value_recently_created, name: project_name) do - = sort_title_recently_created - = link_to admin_groups_path(sort: sort_value_oldest_created, name: project_name) do - = sort_title_oldest_created - = link_to admin_groups_path(sort: sort_value_recently_updated, name: project_name) do - = sort_title_recently_updated - = link_to admin_groups_path(sort: sort_value_oldest_updated, name: project_name) do - = sort_title_oldest_updated - = link_to admin_groups_path(sort: sort_value_largest_group, name: project_name) do - = sort_title_largest_group + = render "shared/groups/dropdown", options_hash: admin_groups_sort_options_hash = link_to new_admin_group_path, class: "btn btn-new" do New group %ul.content-list diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml index 6bf979a937e..23f9927cfee 100644 --- a/app/views/admin/system_info/show.html.haml +++ b/app/views/admin/system_info/show.html.haml @@ -15,7 +15,7 @@ Unable to collect CPU info .col-sm-4 .light-well - %h4 Memory + %h4 Memory Usage .data - if @memory %h1 #{number_to_human_size(@memory.active_bytes)} / #{number_to_human_size(@memory.total_bytes)} @@ -24,7 +24,7 @@ Unable to collect memory info .col-sm-4 .light-well - %h4 Disks + %h4 Disk Usage .data - @disks.each do |disk| %h1 #{number_to_human_size(disk[:bytes_used])} / #{number_to_human_size(disk[:bytes_total])} @@ -34,4 +34,4 @@ .light-well %h4 Uptime .data - %h1= time_ago_with_tooltip(Rails.application.config.booted_at) + %h1= distance_of_time_in_words_to_now(Rails.application.config.booted_at) diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 98ff592eb64..63c5a15de1c 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -157,7 +157,6 @@ %ul %li User will not be able to login %li User will not be able to access git repositories - %li User will be removed from joined projects and groups %li Personal projects will be left %li Owned groups will be left %br diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml index 2bac69bc536..6e399fc7392 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -10,5 +10,7 @@ %p.settings-message.text-center.append-bottom-0 No variables found, add one with the form above. - else - = render "ci/variables/table" - %button.btn.btn-info.js-btn-toggle-reveal-values{ "data-status" => 'hidden' } Reveal Values + .js-secret-variable-table + = render "ci/variables/table" + %button.btn.btn-info.js-secret-value-reveal-button{ data: { secret_reveal_status: 'false' } } + = n_('Reveal value', 'Reveal values', @variables.size) diff --git a/app/views/ci/variables/_table.html.haml b/app/views/ci/variables/_table.html.haml index 71a0b56c4f4..2298930d0c7 100644 --- a/app/views/ci/variables/_table.html.haml +++ b/app/views/ci/variables/_table.html.haml @@ -15,7 +15,11 @@ - if variable.id? %tr %td.variable-key= variable.key - %td.variable-value{ "data-value" => variable.value }****** + %td.variable-value + %span.js-secret-value-placeholder + = '*' * 6 + %span.hide.js-secret-value + = variable.value %td.variable-protected= Gitlab::Utils.boolean_to_yes_no(variable.protected) %td.variable-menu = link_to variable.edit_path, class: "btn btn-transparent btn-variable-edit" do diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index 9a763887b30..f85f5c5be88 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -7,7 +7,8 @@ %span.pushed #{event.action_name} #{event.ref_type} %strong - commits_link = project_commits_path(project, event.ref_name) - = link_to_if project.repository.branch_exists?(event.ref_name), event.ref_name, commits_link, class: 'ref-name' + - should_link = event.tag? ? project.repository.tag_exists?(event.ref_name) : project.repository.branch_exists?(event.ref_name) + = link_to_if should_link, event.ref_name, commits_link, class: 'ref-name' = render "events/event_scope", event: event diff --git a/app/views/help/index.html.haml b/app/views/help/index.html.haml index 021de4f0caf..b8692009225 100644 --- a/app/views/help/index.html.haml +++ b/app/views/help/index.html.haml @@ -1,3 +1,5 @@ += webpack_bundle_tag 'docs' + %div - if current_application_settings.help_page_text.present? = markdown_field(current_application_settings, :help_page_text) @@ -37,8 +39,12 @@ Quick help %ul.well-list %li= link_to 'See our website for getting help', support_url - %li= link_to 'Use the search bar on the top of this page', '#', onclick: 'Shortcuts.focusSearch(event)' - %li= link_to 'Use shortcuts', '#', onclick: 'Shortcuts.toggleHelp()' + %li + %button.btn-blank.btn-link.js-trigger-search-bar{ type: 'button' } + Use the search bar on the top of this page + %li + %button.btn-blank.btn-link.js-trigger-shortcut{ type: 'button' } + Use shortcuts - unless current_application_settings.help_page_hide_commercial_content? %li= link_to 'Get a support subscription', 'https://about.gitlab.com/pricing/' %li= link_to 'Compare GitLab editions', 'https://about.gitlab.com/features/#compare' diff --git a/app/views/ide/index.html.haml b/app/views/ide/index.html.haml new file mode 100644 index 00000000000..8368e7a4563 --- /dev/null +++ b/app/views/ide/index.html.haml @@ -0,0 +1,12 @@ +- page_title 'IDE' + +- content_for :page_specific_javascripts do + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'ide' + +.ide-flash-container.flash-container + +#ide.ide-loading + .text-center + = icon('spinner spin 2x') + %h2.clgray= _('IDE Loading ...') diff --git a/app/views/layouts/nav_only.html.haml b/app/views/layouts/nav_only.html.haml new file mode 100644 index 00000000000..6fa4b39dc10 --- /dev/null +++ b/app/views/layouts/nav_only.html.haml @@ -0,0 +1,13 @@ +!!! 5 +%html{ lang: I18n.locale, class: page_class } + = render "layouts/head" + %body{ class: "#{user_application_theme} #{@body_class}", data: { page: body_data_page } } + = render 'peek/bar' + = render "layouts/header/default" + = render 'shared/outdated_browser' + .mobile-overlay + .alert-wrapper + = render "layouts/broadcast" + = yield :flash_message + = render "layouts/flash" + = yield diff --git a/app/views/notify/pipeline_success_email.html.haml b/app/views/notify/pipeline_success_email.html.haml index 574a8f2fa50..bae37292d62 100644 --- a/app/views/notify/pipeline_success_email.html.haml +++ b/app/views/notify/pipeline_success_email.html.haml @@ -109,7 +109,7 @@ API %tr %td{ colspan: 2, style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;color:#333333;font-size:15px;font-weight:300;line-height:1.4;padding:15px 5px;text-align:center;" } - - job_count = @pipeline.statuses.latest.size + - job_count = @pipeline.total_size - stage_count = @pipeline.stages_count successfully completed #{job_count} #{'job'.pluralize(job_count)} diff --git a/app/views/notify/pipeline_success_email.text.erb b/app/views/notify/pipeline_success_email.text.erb index ddced2279e1..39622cf7f02 100644 --- a/app/views/notify/pipeline_success_email.text.erb +++ b/app/views/notify/pipeline_success_email.text.erb @@ -22,11 +22,11 @@ Committed by: <%= commit.committer_name %> <% end -%> <% end -%> -<% build_count = @pipeline.statuses.latest.size -%> +<% job_count = @pipeline.total_size -%> <% stage_count = @pipeline.stages_count -%> <% if @pipeline.user -%> Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= @pipeline.user.name %> ( <%= user_url(@pipeline.user) %> ) <% else -%> Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API <% end -%> -successfully completed <%= build_count %> <%= 'build'.pluralize(build_count) %> in <%= stage_count %> <%= 'stage'.pluralize(stage_count) %>. +successfully completed <%= job_count %> <%= 'job'.pluralize(job_count) %> in <%= stage_count %> <%= 'stage'.pluralize(stage_count) %>. diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 3a7a99462a6..79530e78154 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -7,7 +7,7 @@ .nav-block = render 'projects/tree/tree_header', tree: @tree - - if !show_new_repo? && commit + - if commit = render 'shared/commit_well', commit: commit, ref: ref, project: project = render 'projects/tree/tree_content', tree: @tree, content_url: content_url diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml index c5e3a7945bd..8212ab9a31e 100644 --- a/app/views/projects/_md_preview.html.haml +++ b/app/views/projects/_md_preview.html.haml @@ -17,7 +17,7 @@ %a.js-md-preview-button{ href: "#md-preview-holder", tabindex: -1 } Preview - %li.md-header-toolbar + %li.md-header-toolbar.active = markdown_toolbar_button({ icon: "bold", data: { "md-tag" => "**" }, title: "Add bold text" }) = markdown_toolbar_button({ icon: "italic", data: { "md-tag" => "*" }, title: "Add italic text" }) = markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: "Insert a quote" }) diff --git a/app/views/projects/_merge_request_merge_settings.html.haml b/app/views/projects/_merge_request_merge_settings.html.haml index 1dd8778f800..f6e5712ce81 100644 --- a/app/views/projects/_merge_request_merge_settings.html.haml +++ b/app/views/projects/_merge_request_merge_settings.html.haml @@ -8,7 +8,7 @@ %br %span.descr Pipelines need to be configured to enable this feature. - = link_to icon('question-circle'), help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds') + = link_to icon('question-circle'), help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds'), target: '_blank' .checkbox = form.label :only_allow_merge_if_all_discussions_are_resolved do = form.check_box :only_allow_merge_if_all_discussions_are_resolved diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index 281363d2e01..2a77dedd9a2 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -12,6 +12,7 @@ .btn-group{ role: "group" }< = edit_blob_link + = ide_blob_link - if current_user = replace_blob_link = delete_blob_link diff --git a/app/views/projects/blob/_header_content.html.haml b/app/views/projects/blob/_header_content.html.haml index 98bedae650a..5d457a50c49 100644 --- a/app/views/projects/blob/_header_content.html.haml +++ b/app/views/projects/blob/_header_content.html.haml @@ -8,3 +8,6 @@ %small = number_to_human_size(blob.raw_size) + + - if blob.stored_externally? && blob.external_storage == :lfs + %span.label.label-lfs.append-right-5 LFS diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index c4712bf3736..4d358052d43 100644 --- a/app/views/projects/blob/show.html.haml +++ b/app/views/projects/blob/show.html.haml @@ -6,21 +6,14 @@ - content_for :page_specific_javascripts do = webpack_bundle_tag 'blob' - - if show_new_repo? - = webpack_bundle_tag 'common_vue' - = webpack_bundle_tag 'repo' - = render 'projects/last_push' %div{ class: container_class } - - if show_new_repo? - = render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_blob_path(@project, @id) - - else - #tree-holder.tree-holder - = render 'blob', blob: @blob + #tree-holder.tree-holder + = render 'blob', blob: @blob - if can_modify_blob?(@blob) = render 'projects/blob/remove' - - title = "Replace #{@blob.name}" - = render 'projects/blob/upload', title: title, placeholder: title, button_title: 'Replace file', form_path: project_update_blob_path(@project, @id), method: :put + - title = "Replace #{@blob.name}" + = render 'projects/blob/upload', title: title, placeholder: title, button_title: 'Replace file', form_path: project_update_blob_path(@project, @id), method: :put diff --git a/app/views/projects/blob/viewers/_dependency_manager.html.haml b/app/views/projects/blob/viewers/_dependency_manager.html.haml index a0f0215a5ff..87aa7c1dbf8 100644 --- a/app/views/projects/blob/viewers/_dependency_manager.html.haml +++ b/app/views/projects/blob/viewers/_dependency_manager.html.haml @@ -6,6 +6,6 @@ - if viewer.package_name and defines a #{viewer.package_type} named %strong< - = link_to viewer.package_name, viewer.package_url, target: '_blank', rel: 'noopener noreferrer' + = link_to_if viewer.package_url.present?, viewer.package_name, viewer.package_url, target: '_blank', rel: 'noopener noreferrer' = link_to 'Learn more', viewer.manager_url, target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/projects/clusters/_cluster.html.haml b/app/views/projects/clusters/_cluster.html.haml index 18ca01d2d49..ad696daa259 100644 --- a/app/views/projects/clusters/_cluster.html.haml +++ b/app/views/projects/clusters/_cluster.html.haml @@ -16,7 +16,8 @@ class: "js-toggle-cluster-list project-feature-toggle #{'is-checked' if cluster.enabled?} #{'is-disabled' if !cluster.can_toggle_cluster?}", "aria-label": s_("ClusterIntegration|Toggle Cluster"), disabled: !cluster.can_toggle_cluster?, - data: { "enabled-text": s_("ClusterIntegration|Active"), - "disabled-text": s_("ClusterIntegration|Inactive"), - endpoint: namespace_project_cluster_path(@project.namespace, @project, cluster, format: :json) } } + data: { endpoint: namespace_project_cluster_path(@project.namespace, @project, cluster, format: :json) } } = icon("spinner spin", class: "loading-icon") + %span.toggle-icon + = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked') + = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked') diff --git a/app/views/projects/clusters/_enabled.html.haml b/app/views/projects/clusters/_enabled.html.haml index 70c677f7856..547b3c8446f 100644 --- a/app/views/projects/clusters/_enabled.html.haml +++ b/app/views/projects/clusters/_enabled.html.haml @@ -7,8 +7,10 @@ %button{ type: 'button', class: "js-toggle-cluster project-feature-toggle #{'is-checked' unless !@cluster.enabled?} #{'is-disabled' unless can?(current_user, :update_cluster, @cluster)}", "aria-label": s_("ClusterIntegration|Toggle Cluster"), - disabled: !can?(current_user, :update_cluster, @cluster), - data: { "enabled-text": s_("ClusterIntegration|Active"), "disabled-text": s_("ClusterIntegration|Inactive"), } } + disabled: !can?(current_user, :update_cluster, @cluster) } + %span.toggle-icon + = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked') + = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked') - if can?(current_user, :update_cluster, @cluster) .form-group diff --git a/app/views/projects/clusters/gcp/_form.html.haml b/app/views/projects/clusters/gcp/_form.html.haml index 0f6bae97571..e384b60d8d9 100644 --- a/app/views/projects/clusters/gcp/_form.html.haml +++ b/app/views/projects/clusters/gcp/_form.html.haml @@ -7,6 +7,9 @@ .form-group = field.label :name, s_('ClusterIntegration|Cluster name') = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Cluster name') + .form-group + = field.label :environment_scope, s_('ClusterIntegration|Environment scope') + = field.text_field :environment_scope, class: 'form-control', readonly: !has_multiple_clusters?(@project), placeholder: s_('ClusterIntegration|Environment scope') = field.fields_for :provider_gcp, @cluster.provider_gcp do |provider_gcp_field| .form-group diff --git a/app/views/projects/clusters/gcp/_show.html.haml b/app/views/projects/clusters/gcp/_show.html.haml index 3fa9f69708a..bde85aed341 100644 --- a/app/views/projects/clusters/gcp/_show.html.haml +++ b/app/views/projects/clusters/gcp/_show.html.haml @@ -8,6 +8,11 @@ = form_for @cluster, url: namespace_project_cluster_path(@project.namespace, @project, @cluster), as: :cluster do |field| = form_errors(@cluster) + + .form-group + = field.label :environment_scope, s_('ClusterIntegration|Environment scope') + = field.text_field :environment_scope, class: 'form-control js-select-on-focus', readonly: !has_multiple_clusters?(@project), placeholder: s_('ClusterIntegration|Environment scope') + = field.fields_for :platform_kubernetes, @cluster.platform_kubernetes do |platform_kubernetes_field| .form-group = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL') diff --git a/app/views/projects/clusters/user/_form.html.haml b/app/views/projects/clusters/user/_form.html.haml index 4a9bd5186c6..babfca0c567 100644 --- a/app/views/projects/clusters/user/_form.html.haml +++ b/app/views/projects/clusters/user/_form.html.haml @@ -3,6 +3,9 @@ .form-group = field.label :name, s_('ClusterIntegration|Cluster name') = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Cluster name') + .form-group + = field.label :environment_scope, s_('ClusterIntegration|Environment scope') + = field.text_field :environment_scope, class: 'form-control', readonly: !has_multiple_clusters?(@project), placeholder: s_('ClusterIntegration|Environment scope') = field.fields_for :platform_kubernetes, @cluster.platform_kubernetes do |platform_kubernetes_field| .form-group diff --git a/app/views/projects/clusters/user/_show.html.haml b/app/views/projects/clusters/user/_show.html.haml index 5931e0b7f17..89595bca007 100644 --- a/app/views/projects/clusters/user/_show.html.haml +++ b/app/views/projects/clusters/user/_show.html.haml @@ -4,6 +4,10 @@ = field.label :name, s_('ClusterIntegration|Cluster name') = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Cluster name') + .form-group + = field.label :environment_scope, s_('ClusterIntegration|Environment scope') + = field.text_field :environment_scope, class: 'form-control js-select-on-focus', readonly: !has_multiple_clusters?(@project), placeholder: s_('ClusterIntegration|Environment scope') + = field.fields_for :platform_kubernetes, @cluster.platform_kubernetes do |platform_kubernetes_field| .form-group = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL') diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 618a6355d23..d66066a6d0b 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -38,8 +38,8 @@ .commiter - commit_author_link = commit_author_link(commit, avatar: false, size: 24) - - commit_timeago = time_ago_with_tooltip(commit.committed_date, placement: 'bottom') - - commit_text = _('%{commit_author_link} committed %{commit_timeago}') % { commit_author_link: commit_author_link, commit_timeago: commit_timeago } + - commit_timeago = time_ago_with_tooltip(commit.authored_date, placement: 'bottom') + - commit_text = _('%{commit_author_link} authored %{commit_timeago}') % { commit_author_link: commit_author_link, commit_timeago: commit_timeago } #{ commit_text.html_safe } .commit-actions.flex-row.hidden-xs diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index d260aaee2d3..1f28d8acff6 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -14,12 +14,12 @@ .detail-page-header .detail-page-header-body - .issuable-status-box.status-box.status-box-closed{ class: issue_button_visibility(@issue, false) } - = icon('check', class: "hidden-sm hidden-md hidden-lg") + .issuable-status-box.status-box.status-box-issue-closed{ class: issue_button_visibility(@issue, false) } + = sprite_icon('mobile-issue-close', size: 16, css_class: 'hidden-sm hidden-md hidden-lg') %span.hidden-xs Closed .issuable-status-box.status-box.status-box-open{ class: issue_button_visibility(@issue, true) } - = icon('circle-o', class: "hidden-sm hidden-md hidden-lg") + = sprite_icon('issue-open-m', size: 16, css_class: 'hidden-sm hidden-md hidden-lg') %span.hidden-xs Open .issuable-meta @@ -39,8 +39,6 @@ = icon('caret-down') .dropdown-menu.dropdown-menu-align-right.hidden-lg %ul - - if can_update_issue - %li= link_to 'Edit', edit_project_issue_path(@project, @issue), class: 'js-issuable-edit' - unless current_user == @issue.author %li= link_to 'Report abuse', new_abuse_report_path(user_id: @issue.author.id, ref_url: issue_url(@issue)) - if can_update_issue @@ -52,9 +50,6 @@ %li.divider %li= link_to 'New issue', new_project_issue_path(@project), title: 'New issue', id: 'new_issue_link' - - if can_update_issue - = link_to 'Edit', edit_project_issue_path(@project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped js-issuable-edit' - = render 'shared/issuable/close_reopen_button', issuable: @issue, can_update: can_update_issue - if can_report_spam diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index bc91758110e..22c8b6b513d 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -7,7 +7,7 @@ .detail-page-header .detail-page-header-body .issuable-status-box.status-box{ class: status_box_class(@merge_request) } - = icon(@merge_request.state_icon_name, class: "hidden-sm hidden-md hidden-lg") + = sprite_icon(@merge_request.state_icon_name, size: 16, css_class: 'hidden-sm hidden-md hidden-lg') %span.hidden-xs = @merge_request.state_human_name diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index abff702fd9d..8740c6895df 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -20,7 +20,7 @@ -# haml-lint:disable InlineJavaScript :javascript window.gl = window.gl || {}; - window.gl.mrWidgetData = #{serialize_issuable(@merge_request)} + window.gl.mrWidgetData = #{serialize_issuable(@merge_request, serializer: 'widget')} #js-vue-mr-widget.mr-widget diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml index bd8c38292d6..f8c4005a9e0 100644 --- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml +++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml @@ -26,10 +26,12 @@ = pipeline_schedule.owner&.name %td .pull-right.btn-group + - if can?(current_user, :play_pipeline_schedule, pipeline_schedule) + = link_to play_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('Play'), class: 'btn' do + = icon('play') - if can?(current_user, :update_pipeline_schedule, pipeline_schedule) = link_to take_ownership_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('PipelineSchedules|Take ownership'), class: 'btn' do = s_('PipelineSchedules|Take ownership') - - if can?(current_user, :update_pipeline_schedule, pipeline_schedule) = link_to edit_pipeline_schedule_path(pipeline_schedule), title: _('Edit'), class: 'btn' do = icon('pencil') - if can?(current_user, :admin_pipeline_schedule, pipeline_schedule) diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index f5149306734..85946aec1f2 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -1,6 +1,6 @@ #js-pipeline-header-vue.pipeline-header-container -- if @commit +- if @commit.present? .commit-box %h3.commit-title = markdown(@commit.title, pipeline: :single_line) @@ -8,28 +8,28 @@ %pre.commit-description = preserve(markdown(@commit.description, pipeline: :single_line)) -.info-well - - if @commit.status - .well-segment.pipeline-info - .icon-container - = icon('clock-o') - = pluralize @pipeline.statuses.count(:id), "job" - - if @pipeline.ref - from - = link_to @pipeline.ref, project_ref_path(@project, @pipeline.ref), class: "ref-name" - - if @pipeline.duration - in - = time_interval_in_words(@pipeline.duration) - - if @pipeline.queued_duration - = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})" + .info-well + - if @commit.status + .well-segment.pipeline-info + .icon-container + = icon('clock-o') + = pluralize @pipeline.total_size, "job" + - if @pipeline.ref + from + = link_to @pipeline.ref, project_ref_path(@project, @pipeline.ref), class: "ref-name" + - if @pipeline.duration + in + = time_interval_in_words(@pipeline.duration) + - if @pipeline.queued_duration + = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})" - .well-segment.branch-info - .icon-container.commit-icon - = custom_icon("icon_commit") - = link_to @commit.short_id, project_commit_path(@project, @pipeline.sha), class: "commit-sha js-details-short" - = link_to("#", class: "js-details-expand hidden-xs hidden-sm") do - %span.text-expander - \... - %span.js-details-content.hide - = link_to @pipeline.sha, project_commit_path(@project, @pipeline.sha), class: "commit-sha commit-hash-full" - = clipboard_button(text: @pipeline.sha, title: "Copy commit SHA to clipboard") + .well-segment.branch-info + .icon-container.commit-icon + = custom_icon("icon_commit") + = link_to @commit.short_id, project_commit_path(@project, @pipeline.sha), class: "commit-sha js-details-short" + = link_to("#", class: "js-details-expand hidden-xs hidden-sm") do + %span.text-expander + \... + %span.js-details-content.hide + = link_to @pipeline.sha, project_commit_path(@project, @pipeline.sha), class: "commit-sha commit-hash-full" + = clipboard_button(text: @pipeline.sha, title: "Copy commit SHA to clipboard") diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index ad61f033a1c..398a1c46746 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -8,7 +8,7 @@ %li.js-builds-tab-link = link_to builds_project_pipeline_path(@project, @pipeline), data: {target: 'div#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do Jobs - %span.badge.js-builds-counter= pipeline.statuses.count + %span.badge.js-builds-counter= pipeline.total_size - if failed_builds.present? %li.js-failures-tab-link = link_to failures_project_pipeline_path(@project, @pipeline), data: {target: 'div#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do diff --git a/app/views/projects/pipelines_settings/_show.html.haml b/app/views/projects/pipelines_settings/_show.html.haml index c63e716180c..c5f9f5aa15b 100644 --- a/app/views/projects/pipelines_settings/_show.html.haml +++ b/app/views/projects/pipelines_settings/_show.html.haml @@ -40,10 +40,14 @@ = form.text_field :domain, class: 'form-control', placeholder: 'domain.com' %hr - .form-group.append-bottom-default + .form-group.append-bottom-default.js-secret-runner-token = f.label :runners_token, "Runner token", class: 'label-light' - = f.text_field :runners_token, class: "form-control", placeholder: 'xEeFCaDAB89' + .form-control.js-secret-value-placeholder + = '*' * 20 + = f.text_field :runners_token, class: "form-control hide js-secret-value", placeholder: 'xEeFCaDAB89' %p.help-block The secure token used by the Runner to checkout the project + %button.btn.btn-info.prepend-top-10.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: 'false' } } + = _('Reveal value') %hr .form-group diff --git a/app/views/projects/tree/_blob_item.html.haml b/app/views/projects/tree/_blob_item.html.haml index c51af901699..8c1c532cb3e 100644 --- a/app/views/projects/tree/_blob_item.html.haml +++ b/app/views/projects/tree/_blob_item.html.haml @@ -1,9 +1,12 @@ +- is_lfs_blob = @lfs_blob_ids.include?(blob_item.id) %tr{ class: "tree-item #{tree_hex_class(blob_item)}" } %td.tree-item-file-name = tree_icon(type, blob_item.mode, blob_item.name) - file_name = blob_item.name = link_to project_blob_path(@project, tree_join(@id || @commit.id, blob_item.name)), class: 'str-truncated', title: file_name do %span= file_name + - if is_lfs_blob + %span.label.label-lfs.prepend-left-5 LFS %td.hidden-xs.tree-commit %td.tree-time-ago.cgray.text-right = render 'projects/tree/spinner' diff --git a/app/views/projects/tree/_old_tree_content.html.haml b/app/views/projects/tree/_old_tree_content.html.haml deleted file mode 100644 index 6ea78851b8d..00000000000 --- a/app/views/projects/tree/_old_tree_content.html.haml +++ /dev/null @@ -1,24 +0,0 @@ -.tree-content-holder.js-tree-content{ 'data-logs-path': @logs_path } - .table-holder - %table.table#tree-slider{ class: "table_#{@hex_path} tree-table" } - %thead - %tr - %th= s_('ProjectFileTree|Name') - %th.hidden-xs - .pull-left= _('Last commit') - %th.text-right= _('Last update') - - if @path.present? - %tr.tree-item - %td.tree-item-file-name - = link_to "..", project_tree_path(@project, up_dir_path), class: 'prepend-left-10' - %td - %td.hidden-xs - - = render_tree(tree) - - - if tree.readme - = render "projects/tree/readme", readme: tree.readme - -- if can_edit_tree? - = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post - = render 'projects/blob/new_dir' diff --git a/app/views/projects/tree/_old_tree_header.html.haml b/app/views/projects/tree/_old_tree_header.html.haml deleted file mode 100644 index 7f636b7e0e8..00000000000 --- a/app/views/projects/tree/_old_tree_header.html.haml +++ /dev/null @@ -1,64 +0,0 @@ -- if on_top_of_branch? - - addtotree_toggle_attributes = { href: '#', 'data-toggle': 'dropdown', 'data-target': '.add-to-tree-dropdown' } -- else - - addtotree_toggle_attributes = { title: _("You can only add files when you are on a branch"), data: { container: 'body' }, class: 'disabled has-tooltip' } - -%ul.breadcrumb.repo-breadcrumb - %li - = link_to project_tree_path(@project, @ref) do - = @project.path - - path_breadcrumbs do |title, path| - %li - = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path)) - - - if current_user - %li - %a.btn.add-to-tree{ addtotree_toggle_attributes } - = sprite_icon('plus', size: 16, css_class: 'pull-left') - = sprite_icon('arrow-down', size: 16, css_class: 'pull-left') - - if on_top_of_branch? - .add-to-tree-dropdown - %ul.dropdown-menu - - if can_edit_tree? - %li - = link_to project_new_blob_path(@project, @id) do - #{ _('New file') } - %li - = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do - #{ _('Upload file') } - %li - = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do - #{ _('New directory') } - - elsif can?(current_user, :fork_project, @project) - %li - - continue_params = { to: project_new_blob_path(@project, @id), - notice: edit_in_new_fork_notice, - notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, - continue: continue_params) - = link_to fork_path, method: :post do - #{ _('New file') } - %li - - continue_params = { to: request.fullpath, - notice: edit_in_new_fork_notice + " Try to upload a file again.", - notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, - continue: continue_params) - = link_to fork_path, method: :post do - #{ _('Upload file') } - %li - - continue_params = { to: request.fullpath, - notice: edit_in_new_fork_notice + " Try to create a new directory again.", - notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, - continue: continue_params) - = link_to fork_path, method: :post do - #{ _('New directory') } - - %li.divider - %li - = link_to new_project_branch_path(@project) do - #{ _('New branch') } - %li - = link_to new_project_tag_path(@project) do - #{ _('New tag') } diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml index a4bdd67209d..6ea78851b8d 100644 --- a/app/views/projects/tree/_tree_content.html.haml +++ b/app/views/projects/tree/_tree_content.html.haml @@ -1,5 +1,24 @@ -- content_url = local_assigns.fetch(:content_url, nil) -- if show_new_repo? - = render 'shared/repo/repo', project: @project, content_url: content_url -- else - = render 'projects/tree/old_tree_content', tree: tree +.tree-content-holder.js-tree-content{ 'data-logs-path': @logs_path } + .table-holder + %table.table#tree-slider{ class: "table_#{@hex_path} tree-table" } + %thead + %tr + %th= s_('ProjectFileTree|Name') + %th.hidden-xs + .pull-left= _('Last commit') + %th.text-right= _('Last update') + - if @path.present? + %tr.tree-item + %td.tree-item-file-name + = link_to "..", project_tree_path(@project, up_dir_path), class: 'prepend-left-10' + %td + %td.hidden-xs + + = render_tree(tree) + + - if tree.readme + = render "projects/tree/readme", readme: tree.readme + +- if can_edit_tree? + = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post + = render 'projects/blob/new_dir' diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index c02f7ee37ed..d1ecef39475 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -2,16 +2,78 @@ .tree-ref-holder = render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true - - if show_new_repo? && can_push_branch?(@project, @ref) - .js-new-dropdown - - else - = render 'projects/tree/old_tree_header' + - if on_top_of_branch? + - addtotree_toggle_attributes = { href: '#', 'data-toggle': 'dropdown', 'data-target': '.add-to-tree-dropdown' } + - else + - addtotree_toggle_attributes = { title: _("You can only add files when you are on a branch"), data: { container: 'body' }, class: 'disabled has-tooltip' } + + %ul.breadcrumb.repo-breadcrumb + %li + = link_to project_tree_path(@project, @ref) do + = @project.path + - path_breadcrumbs do |title, path| + %li + = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path)) + + - if current_user + %li + %a.btn.add-to-tree{ addtotree_toggle_attributes } + = sprite_icon('plus', size: 16, css_class: 'pull-left') + = sprite_icon('arrow-down', size: 16, css_class: 'pull-left') + - if on_top_of_branch? + .add-to-tree-dropdown + %ul.dropdown-menu + - if can_edit_tree? + %li + = link_to project_new_blob_path(@project, @id) do + #{ _('New file') } + %li + = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do + #{ _('Upload file') } + %li + = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do + #{ _('New directory') } + - elsif can?(current_user, :fork_project, @project) + %li + - continue_params = { to: project_new_blob_path(@project, @id), + notice: edit_in_new_fork_notice, + notice_now: edit_in_new_fork_notice_now } + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, + continue: continue_params) + = link_to fork_path, method: :post do + #{ _('New file') } + %li + - continue_params = { to: request.fullpath, + notice: edit_in_new_fork_notice + " Try to upload a file again.", + notice_now: edit_in_new_fork_notice_now } + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, + continue: continue_params) + = link_to fork_path, method: :post do + #{ _('Upload file') } + %li + - continue_params = { to: request.fullpath, + notice: edit_in_new_fork_notice + " Try to create a new directory again.", + notice_now: edit_in_new_fork_notice_now } + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, + continue: continue_params) + = link_to fork_path, method: :post do + #{ _('New directory') } + + %li.divider + %li + = link_to new_project_branch_path(@project) do + #{ _('New branch') } + %li + = link_to new_project_tag_path(@project) do + #{ _('New tag') } .tree-controls - - if show_new_repo? - .editable-mode - - else - = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' + - if show_new_ide? + = succeed " " do + = link_to ide_edit_path(@project, @id), class: 'btn btn-default' do + = ide_edit_text + + = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' = render 'projects/find_file_link' diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index 64cc70053ef..3b4057e56d0 100644 --- a/app/views/projects/tree/show.html.haml +++ b/app/views/projects/tree/show.html.haml @@ -6,11 +6,6 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, project_commits_url(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits") -- if show_new_repo? - - content_for :page_specific_javascripts do - = webpack_bundle_tag 'common_vue' - = webpack_bundle_tag 'repo' - -%div{ class: [(container_class unless show_new_repo?), ("limit-container-width" unless fluid_layout)] } +%div{ class: [(container_class), ("limit-container-width" unless fluid_layout)] } = render 'projects/last_push' = render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id) diff --git a/app/views/shared/_ref_switcher.html.haml b/app/views/shared/_ref_switcher.html.haml index f4a4bfaec54..479bd2cdb38 100644 --- a/app/views/shared/_ref_switcher.html.haml +++ b/app/views/shared/_ref_switcher.html.haml @@ -1,6 +1,6 @@ - show_create = local_assigns.fetch(:show_create, false) -- show_new_branch_form = show_new_repo? && show_create && can?(current_user, :push_code, @project) +- show_new_branch_form = show_new_ide? && show_create && can?(current_user, :push_code, @project) - dropdown_toggle_text = @ref || @project.default_branch = form_tag switch_project_refs_path(@project), method: :get, class: "project-refs-form" do = hidden_field_tag :destination, destination diff --git a/app/views/shared/_show_aside.html.haml b/app/views/shared/_show_aside.html.haml deleted file mode 100644 index 3ac9b11b4fa..00000000000 --- a/app/views/shared/_show_aside.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -= link_to '#aside', class: 'show-aside' do - %i.fa.fa-angle-left diff --git a/app/views/shared/boards/components/_sidebar.html.haml b/app/views/shared/boards/components/_sidebar.html.haml index b3f73e96b81..8e5e32e9f16 100644 --- a/app/views/shared/boards/components/_sidebar.html.haml +++ b/app/views/shared/boards/components/_sidebar.html.haml @@ -1,5 +1,4 @@ -%board-sidebar{ "inline-template" => true, - ":current-user" => "#{current_user ? current_user.to_json(only: [:username, :id, :name], methods: [:avatar_url]) : {}}" } +%board-sidebar{ "inline-template" => true, ":current-user" => (UserSerializer.new.represent(current_user) || {}).to_json } %transition{ name: "boards-sidebar-slide" } %aside.right-sidebar.right-sidebar-expanded.issue-boards-sidebar{ "v-show" => "showSidebar" } .issuable-sidebar diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml index e039a73cd3b..62437f5fc9d 100644 --- a/app/views/shared/empty_states/_issues.html.haml +++ b/app/views/shared/empty_states/_issues.html.haml @@ -8,16 +8,17 @@ = image_tag 'illustrations/issues.svg' .col-xs-12 .text-content - - if has_button && current_user + - if current_user %h4 = _("The Issue Tracker is the place to add things that need to be improved or solved in a project") %p = _("Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable.") - .text-center - - if project_select_button - = render 'shared/new_project_item_select', path: 'issues/new', label: 'New issue', type: :issues - - else - = link_to 'New issue', button_path, class: 'btn btn-success', title: 'New issue', id: 'new_issue_link' + - if has_button + .text-center + - if project_select_button + = render 'shared/new_project_item_select', path: 'issues/new', label: 'New issue', type: :issues + - else + = link_to 'New issue', button_path, class: 'btn btn-success', title: 'New issue', id: 'new_issue_link' - else %h4.text-center= _("There are no issues to show") %p diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml index 8e6747ca740..1a259b679c7 100644 --- a/app/views/shared/groups/_dropdown.html.haml +++ b/app/views/shared/groups/_dropdown.html.haml @@ -1,3 +1,4 @@ +- options_hash = local_assigns.fetch(:options_hash, groups_sort_options_hash) - show_archive_options = local_assigns.fetch(:show_archive_options, false) - if @sort.present? - default_sort_by = @sort @@ -10,12 +11,12 @@ .dropdown.inline.js-group-filter-dropdown-wrap.append-right-10 %button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' } %span.dropdown-label - = sort_options_hash[default_sort_by] + = options_hash[default_sort_by] = icon('chevron-down') %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable %li.dropdown-header = _("Sort by") - - groups_sort_options_hash.each do |value, title| + - options_hash.each do |value, title| %li.js-filter-sort-order = link_to filter_groups_path(sort: value), class: ("is-active" if default_sort_by == value) do = title diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml index 217af7c9fac..fc86f855865 100644 --- a/app/views/shared/issuable/_assignees.html.haml +++ b/app/views/shared/issuable/_assignees.html.haml @@ -1,14 +1,10 @@ -- max_render = 3 -- max = [max_render, issue.assignees.length].min +- max_render = 4 +- assignees_rendering_overflow = issue.assignees.size > max_render +- render_count = assignees_rendering_overflow ? max_render - 1 : max_render +- more_assignees_count = issue.assignees.size - render_count -- issue.assignees.take(max).each do |assignee| +- issue.assignees.take(render_count).each do |assignee| = link_to_member(@project, assignee, name: false, title: "Assigned to :name") -- if issue.assignees.length > max_render - - counter = issue.assignees.length - max_render - - %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', 'original-title' => "+#{counter} more assignees" } } - - if counter < 99 - = "+#{counter}" - - else - 99+ +- if more_assignees_count.positive? + %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', 'original-title' => "+#{more_assignees_count} more assignees" } } +#{more_assignees_count} diff --git a/app/views/shared/repo/_repo.html.haml b/app/views/shared/repo/_repo.html.haml deleted file mode 100644 index 87e8c416194..00000000000 --- a/app/views/shared/repo/_repo.html.haml +++ /dev/null @@ -1,13 +0,0 @@ -- @no_container = true; -#repo{ data: { root: @path.empty?.to_s, - root_url: project_tree_path(project), - url: content_url, - current_branch: @ref, - ref: @commit.id, - project_name: project.name, - project_url: project_path(project), - project_id: project.id, - new_merge_request_url: namespace_project_new_merge_request_path(project.namespace, project, merge_request: { source_branch: '' }), - can_commit: (!!can_push_branch?(project, @ref)).to_s, - on_top_of_branch: (!!on_top_of_branch?(project, @ref)).to_s, - current_path: @path } } diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml new file mode 100644 index 00000000000..268b7028fd9 --- /dev/null +++ b/app/workers/all_queues.yml @@ -0,0 +1,99 @@ +--- +- cronjob:admin_email +- cronjob:expire_build_artifacts +- cronjob:gitlab_usage_ping +- cronjob:import_export_project_cleanup +- cronjob:pipeline_schedule +- cronjob:prune_old_events +- cronjob:remove_expired_group_links +- cronjob:remove_expired_members +- cronjob:remove_old_web_hook_logs +- cronjob:remove_unreferenced_lfs_objects +- cronjob:repository_archive_cache +- cronjob:repository_check_batch +- cronjob:requests_profiles +- cronjob:schedule_update_user_activity +- cronjob:stuck_ci_jobs +- cronjob:stuck_import_jobs +- cronjob:stuck_merge_jobs +- cronjob:trending_projects + +- gcp_cluster:cluster_install_app +- gcp_cluster:cluster_provision +- gcp_cluster:cluster_wait_for_app_installation +- gcp_cluster:wait_for_cluster_creation + +- github_import_advance_stage +- github_importer:github_import_import_diff_note +- github_importer:github_import_import_issue +- github_importer:github_import_import_note +- github_importer:github_import_import_pull_request +- github_importer:github_import_refresh_import_jid +- github_importer:github_import_stage_finish_import +- github_importer:github_import_stage_import_base_data +- github_importer:github_import_stage_import_issues_and_diff_notes +- github_importer:github_import_stage_import_notes +- github_importer:github_import_stage_import_pull_requests +- github_importer:github_import_stage_import_repository + +- pipeline_cache:expire_job_cache +- pipeline_cache:expire_pipeline_cache +- pipeline_creation:create_pipeline +- pipeline_creation:run_pipeline_schedule +- pipeline_default:build_coverage +- pipeline_default:build_trace_sections +- pipeline_default:pipeline_metrics +- pipeline_default:pipeline_notification +- pipeline_default:update_head_pipeline_for_merge_request +- pipeline_hooks:build_hooks +- pipeline_hooks:pipeline_hooks +- pipeline_processing:build_finished +- pipeline_processing:build_queue +- pipeline_processing:build_success +- pipeline_processing:pipeline_process +- pipeline_processing:pipeline_success +- pipeline_processing:pipeline_update +- pipeline_processing:stage_update + +- repository_check:repository_check_clear +- repository_check:repository_check_single_repository + +- default +- mailers # ActionMailer::DeliveryJob.queue_name + +- authorized_projects +- background_migration +- create_gpg_signature +- delete_merged_branches +- delete_user +- email_receiver +- emails_on_push +- expire_build_instance_artifacts +- git_garbage_collect +- gitlab_shell +- group_destroy +- invalid_gpg_signature_update +- irker +- merge +- namespaceless_project_destroy +- new_issue +- new_merge_request +- new_note +- pages +- post_receive +- process_commit +- project_cache +- project_destroy +- project_export +- project_migrate_hashed_storage +- project_service +- propagate_service_template +- reactive_caching +- repository_fork +- repository_import +- storage_migrator +- system_hook_push +- update_merge_requests +- update_user_activity +- upload_checksum +- web_hook diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb index 5efa9180f5e..97d80305bec 100644 --- a/app/workers/build_finished_worker.rb +++ b/app/workers/build_finished_worker.rb @@ -2,7 +2,7 @@ class BuildFinishedWorker include ApplicationWorker include PipelineQueue - enqueue_in group: :processing + queue_namespace :pipeline_processing def perform(build_id) Ci::Build.find_by(id: build_id).try do |build| diff --git a/app/workers/build_hooks_worker.rb b/app/workers/build_hooks_worker.rb index 6705a1c2709..cbfca8c342c 100644 --- a/app/workers/build_hooks_worker.rb +++ b/app/workers/build_hooks_worker.rb @@ -2,7 +2,7 @@ class BuildHooksWorker include ApplicationWorker include PipelineQueue - enqueue_in group: :hooks + queue_namespace :pipeline_hooks def perform(build_id) Ci::Build.find_by(id: build_id) diff --git a/app/workers/build_queue_worker.rb b/app/workers/build_queue_worker.rb index fc775a84dc0..e4f4e6c1d9e 100644 --- a/app/workers/build_queue_worker.rb +++ b/app/workers/build_queue_worker.rb @@ -2,7 +2,7 @@ class BuildQueueWorker include ApplicationWorker include PipelineQueue - enqueue_in group: :processing + queue_namespace :pipeline_processing def perform(build_id) Ci::Build.find_by(id: build_id).try do |build| diff --git a/app/workers/build_success_worker.rb b/app/workers/build_success_worker.rb index ec049821ad7..4b9097bc5e4 100644 --- a/app/workers/build_success_worker.rb +++ b/app/workers/build_success_worker.rb @@ -2,7 +2,7 @@ class BuildSuccessWorker include ApplicationWorker include PipelineQueue - enqueue_in group: :processing + queue_namespace :pipeline_processing def perform(build_id) Ci::Build.find_by(id: build_id).try do |build| diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb index 9c3bdabc49e..37586e161c9 100644 --- a/app/workers/concerns/application_worker.rb +++ b/app/workers/concerns/application_worker.rb @@ -3,13 +3,23 @@ Sidekiq::Worker.extend ActiveSupport::Concern module ApplicationWorker extend ActiveSupport::Concern - include Sidekiq::Worker + include Sidekiq::Worker # rubocop:disable Cop/IncludeSidekiqWorker included do - sidekiq_options queue: base_queue_name + set_queue end module ClassMethods + def inherited(subclass) + subclass.set_queue + end + + def set_queue + queue_name = [queue_namespace, base_queue_name].compact.join(':') + + sidekiq_options queue: queue_name # rubocop:disable Cop/SidekiqOptionsQueue + end + def base_queue_name name .sub(/\AGitlab::/, '') @@ -18,6 +28,16 @@ module ApplicationWorker .tr('/', '_') end + def queue_namespace(new_namespace = nil) + if new_namespace + sidekiq_options queue_namespace: new_namespace + + set_queue + else + get_sidekiq_options['queue_namespace']&.to_s + end + end + def queue get_sidekiq_options['queue'].to_s end diff --git a/app/workers/concerns/cluster_queue.rb b/app/workers/concerns/cluster_queue.rb index a5074d13220..24b9f145220 100644 --- a/app/workers/concerns/cluster_queue.rb +++ b/app/workers/concerns/cluster_queue.rb @@ -5,6 +5,6 @@ module ClusterQueue extend ActiveSupport::Concern included do - sidekiq_options queue: :gcp_cluster + queue_namespace :gcp_cluster end end diff --git a/app/workers/concerns/cronjob_queue.rb b/app/workers/concerns/cronjob_queue.rb index e918bb011e0..b6581779f6a 100644 --- a/app/workers/concerns/cronjob_queue.rb +++ b/app/workers/concerns/cronjob_queue.rb @@ -4,6 +4,7 @@ module CronjobQueue extend ActiveSupport::Concern included do - sidekiq_options queue: :cronjob, retry: false + queue_namespace :cronjob + sidekiq_options retry: false end end diff --git a/app/workers/concerns/gitlab/github_import/queue.rb b/app/workers/concerns/gitlab/github_import/queue.rb index a2bee361b86..22c2ce458e8 100644 --- a/app/workers/concerns/gitlab/github_import/queue.rb +++ b/app/workers/concerns/gitlab/github_import/queue.rb @@ -4,12 +4,14 @@ module Gitlab extend ActiveSupport::Concern included do + queue_namespace :github_importer + # If a job produces an error it may block a stage from advancing # forever. To prevent this from happening we prevent jobs from going to # the dead queue. This does mean some resources may not be imported, but # this is better than a project being stuck in the "import" state # forever. - sidekiq_options queue: 'github_importer', dead: false, retry: 5 + sidekiq_options dead: false, retry: 5 end end end diff --git a/app/workers/concerns/new_issuable.rb b/app/workers/concerns/new_issuable.rb index eb0d6c9c36c..526ed0bad07 100644 --- a/app/workers/concerns/new_issuable.rb +++ b/app/workers/concerns/new_issuable.rb @@ -9,15 +9,15 @@ module NewIssuable end def set_user(user_id) - @user = User.find_by(id: user_id) + @user = User.find_by(id: user_id) # rubocop:disable Gitlab/ModuleWithInstanceVariables - log_error(User, user_id) unless @user + log_error(User, user_id) unless @user # rubocop:disable Gitlab/ModuleWithInstanceVariables end def set_issuable(issuable_id) - @issuable = issuable_class.find_by(id: issuable_id) + @issuable = issuable_class.find_by(id: issuable_id) # rubocop:disable Gitlab/ModuleWithInstanceVariables - log_error(issuable_class, issuable_id) unless @issuable + log_error(issuable_class, issuable_id) unless @issuable # rubocop:disable Gitlab/ModuleWithInstanceVariables end def log_error(record_class, record_id) diff --git a/app/workers/concerns/pipeline_queue.rb b/app/workers/concerns/pipeline_queue.rb index ddf45b91345..e77093a6902 100644 --- a/app/workers/concerns/pipeline_queue.rb +++ b/app/workers/concerns/pipeline_queue.rb @@ -5,14 +5,6 @@ module PipelineQueue extend ActiveSupport::Concern included do - sidekiq_options queue: 'pipeline_default' - end - - class_methods do - def enqueue_in(group:) - raise ArgumentError, 'Unspecified queue group!' if group.empty? - - sidekiq_options queue: "pipeline_#{group}" - end + queue_namespace :pipeline_default end end diff --git a/app/workers/concerns/project_import_options.rb b/app/workers/concerns/project_import_options.rb new file mode 100644 index 00000000000..10b971344f7 --- /dev/null +++ b/app/workers/concerns/project_import_options.rb @@ -0,0 +1,23 @@ +module ProjectImportOptions + extend ActiveSupport::Concern + + included do + IMPORT_RETRY_COUNT = 5 + + sidekiq_options retry: IMPORT_RETRY_COUNT, status_expiration: StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION + + # We only want to mark the project as failed once we exhausted all retries + sidekiq_retries_exhausted do |job| + project = Project.find(job['args'].first) + + action = if project.forked? + "fork" + else + "import" + end + + project.mark_import_as_failed("Every #{action} attempt has failed: #{job['error_message']}. Please try again.") + Sidekiq.logger.warn "Failed #{job['class']} with #{job['args']}: #{job['error_message']}" + end + end +end diff --git a/app/workers/concerns/project_start_import.rb b/app/workers/concerns/project_start_import.rb index 0704ebbb0fd..4e55a1ee3d6 100644 --- a/app/workers/concerns/project_start_import.rb +++ b/app/workers/concerns/project_start_import.rb @@ -1,3 +1,4 @@ +# Used in EE by mirroring module ProjectStartImport def start(project) if project.import_started? && project.import_jid == self.jid diff --git a/app/workers/concerns/repository_check_queue.rb b/app/workers/concerns/repository_check_queue.rb index a597321ccf4..43fb66c31b0 100644 --- a/app/workers/concerns/repository_check_queue.rb +++ b/app/workers/concerns/repository_check_queue.rb @@ -3,6 +3,8 @@ module RepositoryCheckQueue extend ActiveSupport::Concern included do - sidekiq_options queue: :repository_check, retry: false + queue_namespace :repository_check + + sidekiq_options retry: false end end diff --git a/app/workers/create_pipeline_worker.rb b/app/workers/create_pipeline_worker.rb index 00cd7b85b9f..c3ac35e54f5 100644 --- a/app/workers/create_pipeline_worker.rb +++ b/app/workers/create_pipeline_worker.rb @@ -2,7 +2,7 @@ class CreatePipelineWorker include ApplicationWorker include PipelineQueue - enqueue_in group: :creation + queue_namespace :pipeline_creation def perform(project_id, user_id, ref, source, params = {}) project = Project.find(project_id) diff --git a/app/workers/expire_job_cache_worker.rb b/app/workers/expire_job_cache_worker.rb index a591e2da519..7217364a9f2 100644 --- a/app/workers/expire_job_cache_worker.rb +++ b/app/workers/expire_job_cache_worker.rb @@ -2,7 +2,7 @@ class ExpireJobCacheWorker include ApplicationWorker include PipelineQueue - enqueue_in group: :cache + queue_namespace :pipeline_cache def perform(job_id) job = CommitStatus.joins(:pipeline, :project).find_by(id: job_id) diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb index a3ac32b437d..db73d37868a 100644 --- a/app/workers/expire_pipeline_cache_worker.rb +++ b/app/workers/expire_pipeline_cache_worker.rb @@ -2,7 +2,7 @@ class ExpirePipelineCacheWorker include ApplicationWorker include PipelineQueue - enqueue_in group: :cache + queue_namespace :pipeline_cache def perform(pipeline_id) pipeline = Ci::Pipeline.find_by(id: pipeline_id) @@ -13,7 +13,7 @@ class ExpirePipelineCacheWorker store.touch(project_pipelines_path(project)) store.touch(project_pipeline_path(project, pipeline)) - store.touch(commit_pipelines_path(project, pipeline.commit)) if pipeline.commit + store.touch(commit_pipelines_path(project, pipeline.commit)) unless pipeline.commit.nil? store.touch(new_merge_request_pipelines_path(project)) each_pipelines_merge_request_path(project, pipeline) do |path| store.touch(path) diff --git a/app/workers/gitlab/github_import/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb index 400396d5755..f7f498af840 100644 --- a/app/workers/gitlab/github_import/advance_stage_worker.rb +++ b/app/workers/gitlab/github_import/advance_stage_worker.rb @@ -9,7 +9,7 @@ module Gitlab class AdvanceStageWorker include ApplicationWorker - sidekiq_options queue: 'github_importer_advance_stage', dead: false + sidekiq_options dead: false INTERVAL = 30.seconds.to_i diff --git a/app/workers/pages_worker.rb b/app/workers/pages_worker.rb index 62f733c02fc..3ec81d040b4 100644 --- a/app/workers/pages_worker.rb +++ b/app/workers/pages_worker.rb @@ -1,7 +1,7 @@ class PagesWorker include ApplicationWorker - sidekiq_options queue: :pages, retry: false + sidekiq_options retry: false def perform(action, *arg) send(action, *arg) # rubocop:disable GitlabSecurity/PublicSend diff --git a/app/workers/pipeline_hooks_worker.rb b/app/workers/pipeline_hooks_worker.rb index 661c29efe88..c94918ff4ee 100644 --- a/app/workers/pipeline_hooks_worker.rb +++ b/app/workers/pipeline_hooks_worker.rb @@ -2,7 +2,7 @@ class PipelineHooksWorker include ApplicationWorker include PipelineQueue - enqueue_in group: :hooks + queue_namespace :pipeline_hooks def perform(pipeline_id) Ci::Pipeline.find_by(id: pipeline_id) diff --git a/app/workers/pipeline_process_worker.rb b/app/workers/pipeline_process_worker.rb index 07dbf6a971e..24424b3f472 100644 --- a/app/workers/pipeline_process_worker.rb +++ b/app/workers/pipeline_process_worker.rb @@ -2,7 +2,7 @@ class PipelineProcessWorker include ApplicationWorker include PipelineQueue - enqueue_in group: :processing + queue_namespace :pipeline_processing def perform(pipeline_id) Ci::Pipeline.find_by(id: pipeline_id) diff --git a/app/workers/pipeline_success_worker.rb b/app/workers/pipeline_success_worker.rb index 68c40a259e1..2ab0739a17f 100644 --- a/app/workers/pipeline_success_worker.rb +++ b/app/workers/pipeline_success_worker.rb @@ -2,7 +2,7 @@ class PipelineSuccessWorker include ApplicationWorker include PipelineQueue - enqueue_in group: :processing + queue_namespace :pipeline_processing def perform(pipeline_id) Ci::Pipeline.find_by(id: pipeline_id).try do |pipeline| diff --git a/app/workers/pipeline_update_worker.rb b/app/workers/pipeline_update_worker.rb index 24a8a9fbed5..fc9da2d45b1 100644 --- a/app/workers/pipeline_update_worker.rb +++ b/app/workers/pipeline_update_worker.rb @@ -2,7 +2,7 @@ class PipelineUpdateWorker include ApplicationWorker include PipelineQueue - enqueue_in group: :processing + queue_namespace :pipeline_processing def perform(pipeline_id) Ci::Pipeline.find_by(id: pipeline_id) diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index a07ef1705a1..d1c57b82681 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -1,11 +1,8 @@ class RepositoryForkWorker - ForkError = Class.new(StandardError) - include ApplicationWorker include Gitlab::ShellAdapter include ProjectStartImport - - sidekiq_options status_expiration: StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION + include ProjectImportOptions def perform(project_id, forked_from_repository_storage_path, source_disk_path) project = Project.find(project_id) @@ -18,20 +15,12 @@ class RepositoryForkWorker result = gitlab_shell.fork_repository(forked_from_repository_storage_path, source_disk_path, project.repository_storage_path, project.disk_path) - raise ForkError, "Unable to fork project #{project_id} for repository #{source_disk_path} -> #{project.disk_path}" unless result + raise "Unable to fork project #{project_id} for repository #{source_disk_path} -> #{project.disk_path}" unless result project.repository.after_import - raise ForkError, "Project #{project_id} had an invalid repository after fork" unless project.valid_repo? + raise "Project #{project_id} had an invalid repository after fork" unless project.valid_repo? project.import_finish - rescue ForkError => ex - fail_fork(project, ex.message) - raise - rescue => ex - return unless project - - fail_fork(project, ex.message) - raise ForkError, "#{ex.class} #{ex.message}" end private @@ -42,9 +31,4 @@ class RepositoryForkWorker Rails.logger.info("Project #{project.full_path} was in inconsistent state (#{project.import_status}) while forking.") false end - - def fail_fork(project, message) - Rails.logger.error(message) - project.mark_import_as_failed(message) - end end diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index 55715c83cb1..31e2798c36b 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -1,11 +1,8 @@ class RepositoryImportWorker - ImportError = Class.new(StandardError) - include ApplicationWorker include ExceptionBacktrace include ProjectStartImport - - sidekiq_options status_expiration: StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION + include ProjectImportOptions def perform(project_id) project = Project.find(project_id) @@ -23,17 +20,9 @@ class RepositoryImportWorker # to those importers to mark the import process as complete. return if service.async? - raise ImportError, result[:message] if result[:status] == :error + raise result[:message] if result[:status] == :error project.after_import - rescue ImportError => ex - fail_import(project, ex.message) - raise - rescue => ex - return unless project - - fail_import(project, ex.message) - raise ImportError, "#{ex.class} #{ex.message}" end private @@ -44,8 +33,4 @@ class RepositoryImportWorker Rails.logger.info("Project #{project.full_path} was in inconsistent state (#{project.import_status}) while importing.") false end - - def fail_import(project, message) - project.mark_import_as_failed(message) - end end diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb new file mode 100644 index 00000000000..8f5138fc873 --- /dev/null +++ b/app/workers/run_pipeline_schedule_worker.rb @@ -0,0 +1,22 @@ +class RunPipelineScheduleWorker + include ApplicationWorker + include PipelineQueue + + queue_namespace :pipeline_creation + + def perform(schedule_id, user_id) + schedule = Ci::PipelineSchedule.find_by(id: schedule_id) + user = User.find_by(id: user_id) + + return unless schedule && user + + run_pipeline_schedule(schedule, user) + end + + def run_pipeline_schedule(schedule, user) + Ci::CreatePipelineService.new(schedule.project, + user, + ref: schedule.ref) + .execute(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule) + end +end diff --git a/app/workers/stage_update_worker.rb b/app/workers/stage_update_worker.rb index 69f2318d83b..e4b683fca33 100644 --- a/app/workers/stage_update_worker.rb +++ b/app/workers/stage_update_worker.rb @@ -2,7 +2,7 @@ class StageUpdateWorker include ApplicationWorker include PipelineQueue - enqueue_in group: :processing + queue_namespace :pipeline_processing def perform(stage_id) Ci::Stage.find_by(id: stage_id).try do |stage| diff --git a/app/workers/stuck_merge_jobs_worker.rb b/app/workers/stuck_merge_jobs_worker.rb index 36d2a2e6466..16394293c79 100644 --- a/app/workers/stuck_merge_jobs_worker.rb +++ b/app/workers/stuck_merge_jobs_worker.rb @@ -23,7 +23,12 @@ class StuckMergeJobsWorker merge_requests = MergeRequest.where(id: completed_ids) merge_requests.where.not(merge_commit_sha: nil).update_all(state: :merged) - merge_requests.where(merge_commit_sha: nil).update_all(state: :opened, merge_jid: nil) + + merge_requests_to_reopen = merge_requests.where(merge_commit_sha: nil) + + # Do not reopen merge requests using direct queries. + # We rely on state machine callbacks to update head_pipeline_id + merge_requests_to_reopen.each(&:unlock_mr) Rails.logger.info("Updated state of locked merge jobs. JIDs: #{completed_jids.join(', ')}") end diff --git a/app/workers/update_head_pipeline_for_merge_request_worker.rb b/app/workers/update_head_pipeline_for_merge_request_worker.rb index 0a2e9b63578..f09d89aa170 100644 --- a/app/workers/update_head_pipeline_for_merge_request_worker.rb +++ b/app/workers/update_head_pipeline_for_merge_request_worker.rb @@ -1,15 +1,25 @@ class UpdateHeadPipelineForMergeRequestWorker include ApplicationWorker - - sidekiq_options queue: 'pipeline_default' + include PipelineQueue def perform(merge_request_id) merge_request = MergeRequest.find(merge_request_id) pipeline = Ci::Pipeline.where(project: merge_request.source_project, ref: merge_request.source_branch).last return unless pipeline && pipeline.latest? - raise ArgumentError, 'merge request sha does not equal pipeline sha' if merge_request.diff_head_sha != pipeline.sha + + if merge_request.diff_head_sha != pipeline.sha + log_error_message_for(merge_request) + + return + end merge_request.update_attribute(:head_pipeline_id, pipeline.id) end + + def log_error_message_for(merge_request) + Rails.logger.error( + "Outdated head pipeline for active merge request: id=#{merge_request.id}, source_branch=#{merge_request.source_branch}, diff_head_sha=#{merge_request.diff_head_sha}" + ) + end end |