diff options
Diffstat (limited to 'app')
103 files changed, 2030 insertions, 1686 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 242b3e2b990..d963101028a 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -16,6 +16,7 @@ const Api = { usersPath: '/api/:version/users.json', commitPath: '/api/:version/projects/:id/repository/commits', branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch', + createBranchPath: '/api/:version/projects/:id/repository/branches', group(groupId, callback) { const url = Api.buildUrl(Api.groupPath) diff --git a/app/assets/javascripts/behaviors/autosize.js b/app/assets/javascripts/behaviors/autosize.js index e00af4b2fa8..add43b81f6d 100644 --- a/app/assets/javascripts/behaviors/autosize.js +++ b/app/assets/javascripts/behaviors/autosize.js @@ -1,8 +1,8 @@ -import autosize from 'vendor/autosize'; +import Autosize from 'autosize'; document.addEventListener('DOMContentLoaded', () => { const autosizeEls = document.querySelectorAll('.js-autosize'); - autosize(autosizeEls); - autosize.update(autosizeEls); + Autosize(autosizeEls); + Autosize.update(autosizeEls); }); diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index 184665f395c..3f083655f95 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -11,8 +11,7 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager { // Issue boards is slightly different, we handle all the requests async // instead or reloading the page, we just re-fire the list ajax requests this.isHandledAsync = true; - this.cantEdit = cantEdit.filter(i => typeof i === 'string'); - this.cantEditWithValue = cantEdit.filter(i => typeof i === 'object'); + this.cantEdit = cantEdit; } updateObject(path) { @@ -43,9 +42,7 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager { this.filteredSearchInput.dispatchEvent(new Event('input')); } - canEdit(tokenName, tokenValue) { - if (this.cantEdit.includes(tokenName)) return false; - return this.cantEditWithValue.findIndex(token => token.name === tokenName && - token.value === tokenValue) === -1; + canEdit(tokenName) { + return this.cantEdit.indexOf(tokenName) === -1; } } diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 798d7e0d147..ea82958e80d 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -14,18 +14,16 @@ gl.issueBoards.BoardsStore = { }, state: {}, detail: { - issue: {}, + issue: {} }, moving: { issue: {}, - list: {}, + list: {} }, create () { this.state.lists = []; this.filter.path = getUrlParamsArray().join('&'); - this.detail = { - issue: {}, - }; + this.detail = { issue: {} }; }, addList (listObj, defaultAvatar) { const list = new List(listObj, defaultAvatar); diff --git a/app/assets/javascripts/clusters.js b/app/assets/javascripts/clusters.js index 180aa30e98c..661870c226c 100644 --- a/app/assets/javascripts/clusters.js +++ b/app/assets/javascripts/clusters.js @@ -64,19 +64,16 @@ export default class Clusters { this.poll = new Poll({ resource: this.service, method: 'fetchData', - successCallback: (data) => { - const { status, status_reason } = data.data; - this.updateContainer(status, status_reason); - }, - errorCallback: () => { - Flash(s__('ClusterIntegration|Something went wrong on our end.')); - }, + successCallback: data => this.handleSuccess(data), + errorCallback: () => Clusters.handleError(), }); if (!Visibility.hidden()) { this.poll.makeRequest(); } else { - this.service.fetchData(); + this.service.fetchData() + .then(data => this.handleSuccess(data)) + .catch(() => Clusters.handleError()); } Visibility.change(() => { @@ -88,6 +85,15 @@ export default class Clusters { }); } + static handleError() { + Flash(s__('ClusterIntegration|Something went wrong on our end.')); + } + + handleSuccess(data) { + const { status, status_reason } = data.data; + this.updateContainer(status, status_reason); + } + hideAll() { this.errorContainer.classList.add('hidden'); this.successContainer.classList.add('hidden'); diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js index cf8a9b0402b..8d711e3213c 100644 --- a/app/assets/javascripts/filtered_search/dropdown_utils.js +++ b/app/assets/javascripts/filtered_search/dropdown_utils.js @@ -147,16 +147,6 @@ class DropdownUtils { return dataValue !== null; } - static getVisualTokenValues(visualToken) { - const tokenName = visualToken && visualToken.querySelector('.name').textContent.trim(); - let tokenValue = visualToken && visualToken.querySelector('.value') && visualToken.querySelector('.value').textContent.trim(); - if (tokenName === 'label' && tokenValue) { - // remove leading symbol and wrapping quotes - tokenValue = tokenValue.replace(/^~("|')?(.*)/, '$2').replace(/("|')$/, ''); - } - return { tokenName, tokenValue }; - } - // Determines the full search query (visual tokens + input) static getSearchQuery(untilInput = false) { const container = FilteredSearchContainer.container; diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 69c57f923b6..7b233842d5a 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -185,8 +185,8 @@ class FilteredSearchManager { if (e.keyCode === 8 || e.keyCode === 46) { const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); - const { tokenName, tokenValue } = gl.DropdownUtils.getVisualTokenValues(lastVisualToken); - const canEdit = tokenName && this.canEdit && this.canEdit(tokenName, tokenValue); + const sanitizedTokenName = lastVisualToken && lastVisualToken.querySelector('.name').textContent.trim(); + const canEdit = sanitizedTokenName && this.canEdit && this.canEdit(sanitizedTokenName); if (this.filteredSearchInput.value === '' && lastVisualToken && canEdit) { this.filteredSearchInput.value = gl.FilteredSearchVisualTokens.getLastTokenPartial(); gl.FilteredSearchVisualTokens.removeLastTokenPartial(); @@ -336,8 +336,8 @@ class FilteredSearchManager { let canClearToken = t.classList.contains('js-visual-token'); if (canClearToken) { - const { tokenName, tokenValue } = gl.DropdownUtils.getVisualTokenValues(t); - canClearToken = this.canEdit && this.canEdit(tokenName, tokenValue); + const tokenKey = t.querySelector('.name').textContent.trim(); + canClearToken = this.canEdit && this.canEdit(tokenKey); } if (canClearToken) { @@ -469,7 +469,7 @@ class FilteredSearchManager { } hasFilteredSearch = true; - const canEdit = this.canEdit && this.canEdit(sanitizedKey, sanitizedValue); + const canEdit = this.canEdit && this.canEdit(sanitizedKey); gl.FilteredSearchVisualTokens.addFilterVisualToken( sanitizedKey, `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`, diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index 6139e81fe6d..d2f92929b8a 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -38,14 +38,21 @@ class FilteredSearchVisualTokens { } static createVisualTokenElementHTML(canEdit = true) { + let removeTokenMarkup = ''; + if (canEdit) { + removeTokenMarkup = ` + <div class="remove-token" role="button"> + <i class="fa fa-close"></i> + </div> + `; + } + return ` - <div class="${canEdit ? 'selectable' : 'hidden'}" role="button"> + <div class="selectable" role="button"> <div class="name"></div> <div class="value-container"> <div class="value"></div> - <div class="remove-token" role="button"> - <i class="fa fa-close"></i> - </div> + ${removeTokenMarkup} </div> </div> `; diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index ea2e2205077..33a352e158a 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -7,10 +7,12 @@ import { highCountTrim } from '~/lib/utils/text_utility'; * @param {jQuery.Event} e * @param {String} count */ -$(document).on('todo:toggle', (e, count) => { - const parsedCount = parseInt(count, 10); - const $todoPendingCount = $('.todos-count'); +export default function initTodoToggle() { + $(document).on('todo:toggle', (e, count) => { + const parsedCount = parseInt(count, 10); + const $todoPendingCount = $('.todos-count'); - $todoPendingCount.text(highCountTrim(parsedCount)); - $todoPendingCount.toggleClass('hidden', parsedCount === 0); -}); + $todoPendingCount.text(highCountTrim(parsedCount)); + $todoPendingCount.toggleClass('hidden', parsedCount === 0); + }); +} diff --git a/app/assets/javascripts/init_legacy_filters.js b/app/assets/javascripts/init_legacy_filters.js index 1211c2c802c..fcf424408f2 100644 --- a/app/assets/javascripts/init_legacy_filters.js +++ b/app/assets/javascripts/init_legacy_filters.js @@ -1,15 +1,15 @@ /* eslint-disable no-new */ /* global LabelsSelect */ /* global MilestoneSelect */ -/* global IssueStatusSelect */ /* global SubscriptionSelect */ import UsersSelect from './users_select'; +import issueStatusSelect from './issue_status_select'; export default () => { new UsersSelect(); new LabelsSelect(); new MilestoneSelect(); - new IssueStatusSelect(); + issueStatusSelect(); new SubscriptionSelect(); }; diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js index bb509089b1d..4a15ec8b147 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js @@ -1,12 +1,11 @@ /* eslint-disable class-methods-use-this, no-new */ /* global LabelsSelect */ /* global MilestoneSelect */ -/* global IssueStatusSelect */ /* global SubscriptionSelect */ import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; import './milestone_select'; -import './issue_status_select'; +import issueStatusSelect from './issue_status_select'; import './subscription_select'; import './labels_select'; @@ -49,7 +48,7 @@ export default class IssuableBulkUpdateSidebar { initDropdowns() { new LabelsSelect(); new MilestoneSelect(); - new IssueStatusSelect(); + issueStatusSelect(); new SubscriptionSelect(); } diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index 5bc7f8d9cb9..da99394ff90 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -2,11 +2,8 @@ import Cookies from 'js-cookie'; import bp from './breakpoints'; import UsersSelect from './users_select'; -const PARTICIPANTS_ROW_COUNT = 7; - export default class IssuableContext { constructor(currentUser) { - this.initParticipants(); this.userSelect = new UsersSelect(currentUser); $('select.select2').select2({ @@ -51,29 +48,4 @@ export default class IssuableContext { } }); } - - initParticipants() { - $(document).on('click', '.js-participants-more', this.toggleHiddenParticipants); - return $('.js-participants-author').each(function forEachAuthor(i) { - if (i >= PARTICIPANTS_ROW_COUNT) { - $(this).addClass('js-participants-hidden').hide(); - } - }); - } - - toggleHiddenParticipants() { - const currentText = $(this).text().trim(); - const lessText = $(this).data('less-text'); - const originalText = $(this).data('original-text'); - - if (currentText === originalText) { - $(this).text(lessText); - - if (gl.lazyLoader) gl.lazyLoader.loadCheck(); - } else { - $(this).text(originalText); - } - - $('.js-participants-hidden').toggle(); - } } diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 3fc29f9a661..acd5730cf3c 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -6,7 +6,7 @@ import TaskList from './task_list'; import CreateMergeRequestDropdown from './create_merge_request_dropdown'; import IssuablesHelper from './helpers/issuables_helper'; -class Issue { +export default class Issue { constructor() { if ($('a.btn-close').length) { this.taskList = new TaskList({ @@ -147,5 +147,3 @@ class Issue { }); } } - -export default Issue; diff --git a/app/assets/javascripts/issue_status_select.js b/app/assets/javascripts/issue_status_select.js index 56cb536dcde..03546f61d1f 100644 --- a/app/assets/javascripts/issue_status_select.js +++ b/app/assets/javascripts/issue_status_select.js @@ -1,34 +1,23 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, quotes, object-shorthand, no-unused-vars, no-shadow, one-var, one-var-declaration-per-line, comma-dangle, max-len */ -(function() { - this.IssueStatusSelect = (function() { - function IssueStatusSelect() { - $('.js-issue-status').each(function(i, el) { - var fieldName; - fieldName = $(el).data("field-name"); - return $(el).glDropdown({ - selectable: true, - fieldName: fieldName, - toggleLabel: (function(_this) { - return function(selected, el, instance) { - var $item, label; - label = 'Author'; - $item = instance.dropdown.find('.is-active'); - if ($item.length) { - label = $item.text(); - } - return label; - }; - })(this), - clicked: function(options) { - return options.e.preventDefault(); - }, - id: function(obj, el) { - return $(el).data("id"); - } - }); - }); - } - - return IssueStatusSelect; - })(); -}).call(window); +export default function issueStatusSelect() { + $('.js-issue-status').each((i, el) => { + const fieldName = $(el).data('field-name'); + return $(el).glDropdown({ + selectable: true, + fieldName, + toggleLabel(selected, element, instance) { + let label = 'Author'; + const $item = instance.dropdown.find('.is-active'); + if ($item.length) { + label = $item.text(); + } + return label; + }, + clicked(options) { + return options.e.preventDefault(); + }, + id(obj, element) { + return $(element).data('id'); + }, + }); + }); +} diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 1e52963b1dd..84602cf9207 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -8,7 +8,7 @@ import CreateLabelDropdown from './create_label'; (function() { this.LabelsSelect = (function() { - function LabelsSelect(els, options = {}) { + function LabelsSelect(els) { var _this, $els; _this = this; @@ -58,7 +58,6 @@ import CreateLabelDropdown from './create_label'; labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %> <a href="<%- ["",issueURLSplit[1], issueURLSplit[2],""].join("/") %>issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;"> <%- label.title %> </span> </a> <% }); %>'); labelNoneHTMLTemplate = '<span class="no-value">None</span>'; } - const handleClick = options.handleClick; $sidebarLabelTooltip.tooltip(); @@ -317,9 +316,9 @@ import CreateLabelDropdown from './create_label'; }, multiSelect: $dropdown.hasClass('js-multiselect'), vue: $dropdown.hasClass('js-issue-board-sidebar'), - clicked: function(clickEvent) { - const { $el, e, isMarking } = clickEvent; - const label = clickEvent.selectedObj; + clicked: function(options) { + const { $el, e, isMarking } = options; + const label = options.selectedObj; var isIssueIndex, isMRIndex, page, boardsModel; var fadeOutLoader = () => { @@ -392,10 +391,6 @@ import CreateLabelDropdown from './create_label'; .then(fadeOutLoader) .catch(fadeOutLoader); } - else if (handleClick) { - e.preventDefault(); - handleClick(label); - } else { if ($dropdown.hasClass('js-multiselect')) { diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index fd9d0c335a5..d743f20c615 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -54,11 +54,8 @@ import './gl_dropdown'; import './gl_field_error'; import './gl_field_errors'; import './gl_form'; -import './header'; +import initTodoToggle from './header'; import initImporterStatus from './importer_status'; -import './issuable_form'; -import './issue'; -import './issue_status_select'; import './labels_select'; import './layout_nav'; import LazyLoader from './lazy_loader'; @@ -137,6 +134,7 @@ $(function () { initBreadcrumbs(); initImporterStatus(); + initTodoToggle(); // Set the default path for all cookies to GitLab's root directory Cookies.defaults.path = gon.relative_url_root || '/'; diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index 74e5a4f1cea..e7d5325a509 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -5,7 +5,7 @@ import _ from 'underscore'; (function() { this.MilestoneSelect = (function() { - function MilestoneSelect(currentProject, els, options = {}) { + function MilestoneSelect(currentProject, els) { var _this, $els; if (currentProject != null) { _this = this; @@ -136,26 +136,19 @@ import _ from 'underscore'; }, opened: function(e) { const $el = $(e.currentTarget); - if ($dropdown.hasClass('js-issue-board-sidebar') || options.handleClick) { + if ($dropdown.hasClass('js-issue-board-sidebar')) { selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault; } $('a.is-active', $el).removeClass('is-active'); $(`[data-milestone-id="${selectedMilestone}"] > a`, $el).addClass('is-active'); }, vue: $dropdown.hasClass('js-issue-board-sidebar'), - clicked: function(clickEvent) { - const { $el, e } = clickEvent; - let selected = clickEvent.selectedObj; + clicked: function(options) { + const { $el, e } = options; + let selected = options.selectedObj; var data, isIssueIndex, isMRIndex, isSelecting, page, boardsStore; if (!selected) return; - - if (options.handleClick) { - e.preventDefault(); - options.handleClick(selected); - return; - } - page = $('body').attr('data-page'); isIssueIndex = page === 'projects:issues:index'; isMRIndex = (page === page && page === 'projects:merge_requests:index'); diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index ab101a56db8..705bec23b53 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -12,7 +12,7 @@ newline-per-chained-call, no-useless-escape, class-methods-use-this */ import $ from 'jquery'; import _ from 'underscore'; import Cookies from 'js-cookie'; -import autosize from 'vendor/autosize'; +import Autosize from 'autosize'; import 'vendor/jquery.caret'; // required by jquery.atwho import 'vendor/jquery.atwho'; import AjaxCache from '~/lib/utils/ajax_cache'; @@ -25,7 +25,7 @@ import TaskList from './task_list'; import { ajaxPost, isInViewport, getPagePath, scrollToElement, isMetaKey } from './lib/utils/common_utils'; import imageDiffHelper from './image_diff/helpers/index'; -window.autosize = autosize; +window.autosize = Autosize; function normalizeNewlines(str) { return str.replace(/\r\n/g, '\n'); diff --git a/app/assets/javascripts/notes/components/issue_comment_form.vue b/app/assets/javascripts/notes/components/issue_comment_form.vue index ad384a1cc36..db8f85759b2 100644 --- a/app/assets/javascripts/notes/components/issue_comment_form.vue +++ b/app/assets/javascripts/notes/components/issue_comment_form.vue @@ -1,7 +1,7 @@ <script> import { mapActions, mapGetters } from 'vuex'; import _ from 'underscore'; - import autosize from 'vendor/autosize'; + import Autosize from 'autosize'; import Flash from '../../flash'; import Autosave from '../../autosave'; import TaskList from '../../task_list'; @@ -219,7 +219,7 @@ }, resizeTextarea() { this.$nextTick(() => { - autosize.update(this.$refs.textarea); + Autosize.update(this.$refs.textarea); }); }, }, diff --git a/app/assets/javascripts/repo/components/new_branch_form.vue b/app/assets/javascripts/repo/components/new_branch_form.vue index eac43e692b0..ba7090e4a9d 100644 --- a/app/assets/javascripts/repo/components/new_branch_form.vue +++ b/app/assets/javascripts/repo/components/new_branch_form.vue @@ -1,18 +1,12 @@ <script> + import { mapState, mapActions } from 'vuex'; import flash, { hideFlash } from '../../flash'; import loadingIcon from '../../vue_shared/components/loading_icon.vue'; - import eventHub from '../event_hub'; export default { components: { loadingIcon, }, - props: { - currentBranch: { - type: String, - required: true, - }, - }, data() { return { branchName: '', @@ -20,11 +14,17 @@ }; }, computed: { + ...mapState([ + 'currentBranch', + ]), btnDisabled() { return this.loading || this.branchName === ''; }, }, methods: { + ...mapActions([ + 'createNewBranch', + ]), toggleDropdown() { this.$dropdown.dropdown('toggle'); }, @@ -38,19 +38,21 @@ hideFlash(flashEl, false); } - eventHub.$emit('createNewBranch', this.branchName); - }, - showErrorMessage(message) { - this.loading = false; - flash(message, 'alert', this.$el); - }, - createdNewBranch(newBranchName) { - this.loading = false; - this.branchName = ''; + this.createNewBranch(this.branchName) + .then(() => { + this.loading = false; + this.branchName = ''; - if (this.dropdownText) { - this.dropdownText.textContent = newBranchName; - } + if (this.dropdownText) { + this.dropdownText.textContent = this.currentBranch; + } + + this.toggleDropdown(); + }) + .catch(res => res.json().then((data) => { + this.loading = false; + flash(data.message, 'alert', this.$el); + })); }, }, created() { @@ -59,15 +61,6 @@ // text element is outside Vue app this.dropdownText = document.querySelector('.project-refs-form .dropdown-toggle-text'); - - eventHub.$on('createNewBranchSuccess', this.createdNewBranch); - eventHub.$on('createNewBranchError', this.showErrorMessage); - eventHub.$on('toggleNewBranchDropdown', this.toggleDropdown); - }, - destroyed() { - eventHub.$off('createNewBranchSuccess', this.createdNewBranch); - eventHub.$off('toggleNewBranchDropdown', this.toggleDropdown); - eventHub.$off('createNewBranchError', this.showErrorMessage); }, }; </script> diff --git a/app/assets/javascripts/repo/components/new_dropdown/index.vue b/app/assets/javascripts/repo/components/new_dropdown/index.vue index 3ccb50213ab..a5ee4f71281 100644 --- a/app/assets/javascripts/repo/components/new_dropdown/index.vue +++ b/app/assets/javascripts/repo/components/new_dropdown/index.vue @@ -1,20 +1,24 @@ <script> - import RepoStore from '../../stores/repo_store'; - import RepoHelper from '../../helpers/repo_helper'; - import eventHub from '../../event_hub'; + import { mapState } from 'vuex'; import newModal from './modal.vue'; + import upload from './upload.vue'; export default { components: { newModal, + upload, }, data() { return { openModal: false, modalType: '', - currentPath: RepoStore.path, }; }, + computed: { + ...mapState([ + 'path', + ]), + }, methods: { createNewItem(type) { this.modalType = type; @@ -23,17 +27,6 @@ toggleModalOpen() { this.openModal = !this.openModal; }, - createNewEntryInStore(name, type) { - RepoHelper.createNewEntry(name, type); - - this.toggleModalOpen(); - }, - }, - created() { - eventHub.$on('createNewEntry', this.createNewEntryInStore); - }, - beforeDestroy() { - eventHub.$off('createNewEntry', this.createNewEntryInStore); }, }; </script> @@ -65,6 +58,11 @@ </a> </li> <li> + <upload + :path="path" + /> + </li> + <li> <a href="#" role="button" @@ -79,7 +77,7 @@ <new-modal v-if="openModal" :type="modalType" - :current-path="currentPath" + :path="path" @toggle="toggleModalOpen" /> </div> diff --git a/app/assets/javascripts/repo/components/new_dropdown/modal.vue b/app/assets/javascripts/repo/components/new_dropdown/modal.vue index 5ef629e0dde..ac1f613bb71 100644 --- a/app/assets/javascripts/repo/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/repo/components/new_dropdown/modal.vue @@ -1,30 +1,38 @@ <script> + import { mapActions } from 'vuex'; import { __ } from '../../../locale'; import popupDialog from '../../../vue_shared/components/popup_dialog.vue'; - import eventHub from '../../event_hub'; export default { props: { - currentPath: { + type: { type: String, required: true, }, - type: { + path: { type: String, required: true, }, }, data() { return { - entryName: this.currentPath !== '' ? `${this.currentPath}/` : '', + entryName: this.path !== '' ? `${this.path}/` : '', }; }, components: { popupDialog, }, methods: { + ...mapActions([ + 'createTempEntry', + ]), createEntryInStore() { - eventHub.$emit('createNewEntry', this.entryName, this.type); + this.createTempEntry({ + name: this.entryName.replace(new RegExp(`^${this.path}/`), ''), + type: this.type, + }); + + this.toggleModalOpen(); }, toggleModalOpen() { this.$emit('toggle'); diff --git a/app/assets/javascripts/repo/components/new_dropdown/upload.vue b/app/assets/javascripts/repo/components/new_dropdown/upload.vue new file mode 100644 index 00000000000..14ad32f4ae0 --- /dev/null +++ b/app/assets/javascripts/repo/components/new_dropdown/upload.vue @@ -0,0 +1,68 @@ +<script> + import { mapActions } from 'vuex'; + + export default { + props: { + path: { + type: String, + required: true, + }, + }, + methods: { + ...mapActions([ + 'createTempEntry', + ]), + createFile(target, file, isText) { + const { name } = file; + let { result } = target; + + if (!isText) { + result = result.split('base64,')[1]; + } + + this.createTempEntry({ + name, + type: 'blob', + content: result, + base64: !isText, + }); + }, + readFile(file) { + const reader = new FileReader(); + const isText = file.type.match(/text.*/) !== null; + + reader.addEventListener('load', e => this.createFile(e.target, file, isText), { once: true }); + + if (isText) { + reader.readAsText(file); + } else { + reader.readAsDataURL(file); + } + }, + openFile() { + Array.from(this.$refs.fileUpload.files).forEach(file => this.readFile(file)); + }, + }, + mounted() { + this.$refs.fileUpload.addEventListener('change', this.openFile); + }, + beforeDestroy() { + this.$refs.fileUpload.removeEventListener('change', this.openFile); + }, + }; +</script> + +<template> + <label + role="button" + class="menu-item" + > + {{ __('Upload file') }} + <input + id="file-upload" + type="file" + class="hidden" + ref="fileUpload" + /> + </label> +</template> diff --git a/app/assets/javascripts/repo/components/repo.vue b/app/assets/javascripts/repo/components/repo.vue index 788976a9804..98117802016 100644 --- a/app/assets/javascripts/repo/components/repo.vue +++ b/app/assets/javascripts/repo/components/repo.vue @@ -1,102 +1,59 @@ <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 RepoMixin from '../mixins/repo_mixin'; -import PopupDialog from '../../vue_shared/components/popup_dialog.vue'; -import Store from '../stores/repo_store'; -import Helper from '../helpers/repo_helper'; -import Service from '../services/repo_service'; -import MonacoLoaderHelper from '../helpers/monaco_loader_helper'; -import eventHub from '../event_hub'; +import repoEditor from './repo_editor.vue'; export default { - data() { - return Store; + computed: { + ...mapState([ + 'currentBlobView', + ]), + ...mapGetters([ + 'isCollapsed', + 'changedFiles', + ]), }, - mixins: [RepoMixin], components: { RepoSidebar, RepoTabs, RepoFileButtons, - 'repo-editor': MonacoLoaderHelper.repoEditorLoader, + repoEditor, RepoCommitSection, - PopupDialog, RepoPreview, }, - created() { - eventHub.$on('createNewBranch', this.createNewBranch); - }, mounted() { - Helper.getContent().catch(Helper.loadingError); - }, - destroyed() { - eventHub.$off('createNewBranch', this.createNewBranch); - }, - methods: { - getCurrentLocation() { - return location.href; - }, - toggleDialogOpen(toggle) { - this.dialog.open = toggle; - }, - - dialogSubmitted(status) { - this.toggleDialogOpen(false); - this.dialog.status = status; - - // remove tmp files - Helper.removeAllTmpFiles('openedFiles'); - Helper.removeAllTmpFiles('files'); - }, - toggleBlobView: Store.toggleBlobView, - createNewBranch(branch) { - Service.createBranch({ - branch, - ref: Store.currentBranch, - }).then((res) => { - const newBranchName = res.data.name; - const newUrl = this.getCurrentLocation().replace(Store.currentBranch, newBranchName); - - Store.currentBranch = newBranchName; - - history.pushState({ key: Helper.key }, '', newUrl); + const returnValue = 'Are you sure you want to lose unsaved changes?'; + window.onbeforeunload = (e) => { + if (!this.changedFiles.length) return undefined; - eventHub.$emit('createNewBranchSuccess', newBranchName); - eventHub.$emit('toggleNewBranchDropdown'); - }).catch((err) => { - eventHub.$emit('createNewBranchError', err.response.data.message); + Object.assign(e, { + returnValue, }); - }, + return returnValue; + }; }, }; </script> <template> <div class="repository-view"> - <div class="tree-content-holder" :class="{'tree-content-holder-mini' : isMini}"> + <div class="tree-content-holder" :class="{'tree-content-holder-mini' : isCollapsed}"> <repo-sidebar/> - <div v-if="isMini" - class="panel-right" - :class="{'edit-mode': editMode}"> + <div + v-if="isCollapsed" + class="panel-right" + > <repo-tabs/> <component :is="currentBlobView" - class="blob-viewer-container"/> + /> <repo-file-buttons/> </div> </div> - <repo-commit-section/> - <popup-dialog - v-show="dialog.open" - :primary-button-label="__('Discard changes')" - kind="warning" - :title="__('Are you sure?')" - :text="__('Are you sure you want to discard your changes?')" - @toggle="toggleDialogOpen" - @submit="dialogSubmitted" - /> + <repo-commit-section v-if="changedFiles.length" /> </div> </template> diff --git a/app/assets/javascripts/repo/components/repo_commit_section.vue b/app/assets/javascripts/repo/components/repo_commit_section.vue index 0d6259a37a8..377e3d65348 100644 --- a/app/assets/javascripts/repo/components/repo_commit_section.vue +++ b/app/assets/javascripts/repo/components/repo_commit_section.vue @@ -1,141 +1,100 @@ <script> -import Flash from '../../flash'; -import Store from '../stores/repo_store'; -import RepoMixin from '../mixins/repo_mixin'; -import Service from '../services/repo_service'; +import { mapGetters, mapState, mapActions } from 'vuex'; import PopupDialog from '../../vue_shared/components/popup_dialog.vue'; -import { visitUrl } from '../../lib/utils/url_utility'; +import { n__ } from '../../locale'; export default { - mixins: [RepoMixin], - - data() { - return Store; - }, - components: { PopupDialog, }, - + data() { + return { + showNewBranchDialog: false, + submitCommitsLoading: false, + startNewMR: false, + commitMessage: '', + }; + }, computed: { - showCommitable() { - return this.isCommitable && this.changedFiles.length; - }, - - branchPaths() { - return this.changedFiles.map(f => f.path); - }, - - cantCommitYet() { + ...mapState([ + 'currentBranch', + ]), + ...mapGetters([ + 'changedFiles', + ]), + commitButtonDisabled() { return !this.commitMessage || this.submitCommitsLoading; }, - - filePluralize() { - return this.changedFiles.length > 1 ? 'files' : 'file'; + commitButtonText() { + return n__('Commit %d file', 'Commit %d files', this.changedFiles.length); }, }, - methods: { - commitToNewBranch(status) { - if (status) { - this.showNewBranchDialog = false; - this.tryCommit(null, true, true); - } else { - // reset the state - } - }, + ...mapActions([ + 'checkCommitStatus', + 'commitChanges', + 'getTreeData', + ]), + makeCommit(newBranch = false) { + const createNewBranch = newBranch || this.startNewMR; - makeCommit(newBranch) { - // see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions - const commitMessage = this.commitMessage; - const actions = this.changedFiles.map(f => ({ - action: f.tempFile ? 'create' : 'update', - file_path: f.path, - content: f.newContent, - })); - const branch = newBranch ? `${this.currentBranch}-${this.currentShortHash}` : this.currentBranch; const payload = { - branch, - commit_message: commitMessage, - actions, + branch: createNewBranch ? `${this.currentBranch}-${new Date().getTime().toString()}` : this.currentBranch, + commit_message: this.commitMessage, + actions: this.changedFiles.map(f => ({ + action: f.tempFile ? 'create' : 'update', + file_path: f.path, + content: f.content, + encoding: f.base64 ? 'base64' : 'text', + })), + start_branch: createNewBranch ? this.currentBranch : undefined, }; - if (newBranch) { - payload.start_branch = this.currentBranch; - } - Service.commitFiles(payload) + + this.showNewBranchDialog = false; + this.submitCommitsLoading = true; + + this.commitChanges({ payload, newMr: this.startNewMR }) .then(() => { - this.resetCommitState(); - if (this.startNewMR) { - this.redirectToNewMr(branch); - } else { - this.redirectToBranch(branch); - } + this.submitCommitsLoading = false; + this.getTreeData(); }) .catch(() => { - Flash('An error occurred while committing your changes'); + this.submitCommitsLoading = false; }); }, - - tryCommit(e, skipBranchCheck = false, newBranch = false) { + tryCommit() { this.submitCommitsLoading = true; - if (skipBranchCheck) { - this.makeCommit(newBranch); - } else { - Store.setBranchHash() - .then(() => { - if (Store.branchChanged) { - Store.showNewBranchDialog = true; - return; - } - this.makeCommit(newBranch); - }) - .catch(() => { - this.submitCommitsLoading = false; - Flash('An error occurred while committing your changes'); - }); - } - }, - - redirectToNewMr(branch) { - visitUrl(this.newMrTemplateUrl.replace('{{source_branch}}', branch)); - }, - - redirectToBranch(branch) { - visitUrl(this.customBranchURL.replace('{{branch}}', branch)); - }, - - resetCommitState() { - this.submitCommitsLoading = false; - this.openedFiles = this.openedFiles.map((file) => { - const f = file; - f.changed = false; - return f; - }); - this.changedFiles = []; - this.commitMessage = ''; - this.editMode = false; - window.scrollTo(0, 0); + this.checkCommitStatus() + .then((branchChanged) => { + if (branchChanged) { + this.showNewBranchDialog = true; + } else { + this.makeCommit(); + } + }) + .catch(() => { + this.submitCommitsLoading = false; + }); }, }, }; </script> <template> -<div - v-if="showCommitable" - id="commit-area"> +<div id="commit-area"> <popup-dialog v-if="showNewBranchDialog" :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?')" - @submit="commitToNewBranch" + @toggle="showNewBranchDialog = false" + @submit="makeCommit(true)" /> <form class="form-horizontal" - @submit.prevent="tryCommit"> + @submit.prevent="tryCommit()"> <fieldset> <div class="form-group"> <label class="col-md-4 control-label staged-files"> @@ -144,10 +103,10 @@ export default { <div class="col-md-6"> <ul class="list-unstyled changed-files"> <li - v-for="branchPath in branchPaths" - :key="branchPath"> + v-for="(file, index) in changedFiles" + :key="index"> <span class="help-block"> - {{branchPath}} + {{ file.path }} </span> </li> </ul> @@ -182,9 +141,8 @@ export default { </div> <div class="col-md-offset-4 col-md-6"> <button - ref="submitCommit" type="submit" - :disabled="cantCommitYet" + :disabled="commitButtonDisabled" class="btn btn-success"> <i v-if="submitCommitsLoading" @@ -193,7 +151,7 @@ export default { aria-label="loading"> </i> <span class="commit-summary"> - Commit {{changedFiles.length}} {{filePluralize}} + {{ commitButtonText }} </span> </button> </div> diff --git a/app/assets/javascripts/repo/components/repo_edit_button.vue b/app/assets/javascripts/repo/components/repo_edit_button.vue index e6e8b2e5205..6c1bb4b8566 100644 --- a/app/assets/javascripts/repo/components/repo_edit_button.vue +++ b/app/assets/javascripts/repo/components/repo_edit_button.vue @@ -1,50 +1,57 @@ <script> -import Store from '../stores/repo_store'; -import RepoMixin from '../mixins/repo_mixin'; +import { mapGetters, mapActions, mapState } from 'vuex'; +import popupDialog from '../../vue_shared/components/popup_dialog.vue'; export default { - data() { - return Store; + components: { + popupDialog, }, - mixins: [RepoMixin], computed: { + ...mapState([ + 'editMode', + 'discardPopupOpen', + ]), + ...mapGetters([ + 'canEditFile', + ]), buttonLabel() { return this.editMode ? this.__('Cancel edit') : this.__('Edit'); }, - - showButton() { - return this.isCommitable && - !this.activeFile.render_error && - !this.binary && - this.openedFiles.length; - }, }, methods: { - editCancelClicked() { - if (this.changedFiles.length) { - this.dialog.open = true; - return; - } - this.editMode = !this.editMode; - Store.toggleBlobView(); - }, + ...mapActions([ + 'toggleEditMode', + 'closeDiscardPopup', + ]), }, }; </script> <template> -<button - v-if="showButton" - class="btn btn-default" - type="button" - @click.prevent="editCancelClicked"> - <i - v-if="!editMode" - class="fa fa-pencil" - aria-hidden="true"> - </i> - <span> - {{buttonLabel}} - </span> -</button> + <div class="editable-mode"> + <button + v-if="canEditFile" + class="btn btn-default" + type="button" + @click.prevent="toggleEditMode()"> + <i + v-if="!editMode" + class="fa fa-pencil" + aria-hidden="true"> + </i> + <span> + {{buttonLabel}} + </span> + </button> + <popup-dialog + v-if="discardPopupOpen" + class="text-left" + :primary-button-label="__('Discard changes')" + kind="warning" + :title="__('Are you sure?')" + :text="__('Are you sure you want to discard your changes?')" + @toggle="closeDiscardPopup" + @submit="toggleEditMode(true)" + /> + </div> </template> diff --git a/app/assets/javascripts/repo/components/repo_editor.vue b/app/assets/javascripts/repo/components/repo_editor.vue index df4caba51d8..0d6729bb99b 100644 --- a/app/assets/javascripts/repo/components/repo_editor.vue +++ b/app/assets/javascripts/repo/components/repo_editor.vue @@ -1,124 +1,101 @@ <script> /* global monaco */ -import Store from '../stores/repo_store'; -import Service from '../services/repo_service'; -import Helper from '../helpers/repo_helper'; - -const RepoEditor = { - data() { - return Store; - }, +import { mapGetters, mapActions } from 'vuex'; +import flash from '../../flash'; +import monacoLoader from '../monaco_loader'; +export default { destroyed() { - if (Helper.monacoInstance) { - Helper.monacoInstance.destroy(); + if (this.monacoInstance) { + this.monacoInstance.destroy(); } }, - mounted() { - Service.getRaw(this.activeFile) - .then((rawResponse) => { - Store.blobRaw = rawResponse.data; - Store.activeFile.plain = rawResponse.data; - - const monacoInstance = Helper.monaco.editor.create(this.$el, { - model: null, - readOnly: false, - contextmenu: true, - scrollBeyondLastLine: false, - }); + if (this.monaco) { + this.initMonaco(); + } else { + monacoLoader(['vs/editor/editor.main'], () => { + this.monaco = monaco; + + this.initMonaco(); + }); + } + }, + methods: { + ...mapActions([ + 'getRawFileData', + 'changeFileContent', + ]), + initMonaco() { + if (this.monacoInstance) { + this.monacoInstance.setModel(null); + } - Helper.monacoInstance = monacoInstance; + this.getRawFileData(this.activeFile) + .then(() => { + if (!this.monacoInstance) { + this.monacoInstance = this.monaco.editor.create(this.$el, { + model: null, + readOnly: false, + contextmenu: true, + scrollBeyondLastLine: false, + }); - this.addMonacoEvents(); + this.languages = this.monaco.languages.getLanguages(); - this.setupEditor(); - }) - .catch(Helper.loadingError); - }, + this.addMonacoEvents(); + } - methods: { + this.setupEditor(); + }) + .catch(() => flash('Error setting up monaco. Please try again.')); + }, setupEditor() { - this.showHide(); + if (!this.activeFile) return; + const content = this.activeFile.content !== '' ? this.activeFile.content : this.activeFile.raw; - Helper.setMonacoModelFromLanguage(); - }, + const foundLang = this.languages.find(lang => + lang.extensions && lang.extensions.indexOf(this.activeFileExtension) === 0, + ); + const newModel = this.monaco.editor.createModel( + content, foundLang ? foundLang.id : 'plaintext', + ); - showHide() { - if (!this.openedFiles.length || (this.binary && !this.activeFile.raw)) { - this.$el.style.display = 'none'; - } else { - this.$el.style.display = 'inline-block'; - } + this.monacoInstance.setModel(newModel); }, - addMonacoEvents() { - Helper.monacoInstance.onMouseUp(this.onMonacoEditorMouseUp); - Helper.monacoInstance.onKeyUp(this.onMonacoEditorKeysPressed.bind(this)); - }, - - onMonacoEditorKeysPressed() { - Store.setActiveFileContents(Helper.monacoInstance.getValue()); - }, - - onMonacoEditorMouseUp(e) { - if (!e.target.position) return; - const lineNumber = e.target.position.lineNumber; - if (e.target.element.classList.contains('line-numbers')) { - location.hash = `L${lineNumber}`; - Store.setActiveLine(lineNumber); - } + this.monacoInstance.onKeyUp(() => { + this.changeFileContent({ + file: this.activeFile, + content: this.monacoInstance.getValue(), + }); + }); }, }, - watch: { - dialog: { - handler(obj) { - const newObj = obj; - if (newObj.status) { - newObj.status = false; - this.openedFiles = this.openedFiles.map((file) => { - const f = file; - if (f.active) { - this.blobRaw = f.plain; - } - f.changed = false; - delete f.newContent; - - return f; - }); - this.editMode = false; - Store.toggleBlobView(); - } - }, - deep: true, - }, - - blobRaw() { - if (Helper.monacoInstance) { - this.setupEditor(); - } - }, - - activeLine() { - if (Helper.monacoInstance) { - Helper.monacoInstance.setPosition({ - lineNumber: this.activeLine, - column: 1, - }); + activeFile(oldVal, newVal) { + if (newVal && !newVal.active) { + this.initMonaco(); } }, }, computed: { + ...mapGetters([ + 'activeFile', + 'activeFileExtension', + ]), shouldHideEditor() { - return !this.openedFiles.length || (this.binary && !this.activeFile.raw); + return this.activeFile.binary && !this.activeFile.raw; }, }, }; - -export default RepoEditor; </script> <template> -<div id="ide" v-if='!shouldHideEditor'></div> + <div + id="ide" + v-if='!shouldHideEditor' + class="blob-viewer-container blob-editor-container" + > + </div> </template> diff --git a/app/assets/javascripts/repo/components/repo_file.vue b/app/assets/javascripts/repo/components/repo_file.vue index 8c86e87ed3a..7a23154b340 100644 --- a/app/assets/javascripts/repo/components/repo_file.vue +++ b/app/assets/javascripts/repo/components/repo_file.vue @@ -1,11 +1,9 @@ <script> + import { mapActions, mapGetters } from 'vuex'; import timeAgoMixin from '../../vue_shared/mixins/timeago'; - import eventHub from '../event_hub'; - import repoMixin from '../mixins/repo_mixin'; export default { mixins: [ - repoMixin, timeAgoMixin, ], props: { @@ -15,13 +13,15 @@ }, }, computed: { + ...mapGetters([ + 'isCollapsed', + ]), fileIcon() { - const classObj = { + return { 'fa-spinner fa-spin': this.file.loading, [this.file.icon]: !this.file.loading, 'fa-folder-open': !this.file.loading && this.file.opened, }; - return classObj; }, levelIndentation() { return { @@ -33,9 +33,9 @@ }, }, methods: { - linkClicked(file) { - eventHub.$emit('fileNameClicked', file); - }, + ...mapActions([ + 'clickedTreeRow', + ]), }, }; </script> @@ -43,7 +43,7 @@ <template> <tr class="file" - @click.prevent="linkClicked(file)"> + @click.prevent="clickedTreeRow(file)"> <td> <i class="fa fa-fw file-icon" @@ -71,7 +71,7 @@ </template> </td> - <template v-if="!isMini"> + <template v-if="!isCollapsed"> <td class="hidden-sm hidden-xs"> <a @click.stop diff --git a/app/assets/javascripts/repo/components/repo_file_buttons.vue b/app/assets/javascripts/repo/components/repo_file_buttons.vue index c98f641c853..dd948ee84fb 100644 --- a/app/assets/javascripts/repo/components/repo_file_buttons.vue +++ b/app/assets/javascripts/repo/components/repo_file_buttons.vue @@ -1,37 +1,22 @@ <script> -import Store from '../stores/repo_store'; -import Helper from '../helpers/repo_helper'; -import RepoMixin from '../mixins/repo_mixin'; - -const RepoFileButtons = { - data() { - return Store; - }, - - mixins: [RepoMixin], +import { mapGetters } from 'vuex'; +export default { computed: { + ...mapGetters([ + 'activeFile', + ]), showButtons() { - return this.activeFile.raw_path || - this.activeFile.blame_path || - this.activeFile.commits_path || + return this.activeFile.rawPath || + this.activeFile.blamePath || + this.activeFile.commitsPath || this.activeFile.permalink; }, rawDownloadButtonLabel() { - return this.binary ? 'Download' : 'Raw'; - }, - - canPreview() { - return Helper.isRenderable(); + return this.activeFile.binary ? 'Download' : 'Raw'; }, }, - - methods: { - rawPreviewToggle: Store.toggleRawPreview, - }, }; - -export default RepoFileButtons; </script> <template> @@ -40,11 +25,11 @@ export default RepoFileButtons; class="repo-file-buttons" > <a - :href="activeFile.raw_path" + :href="activeFile.rawPath" target="_blank" class="btn btn-default raw" rel="noopener noreferrer"> - {{rawDownloadButtonLabel}} + {{ rawDownloadButtonLabel }} </a> <div @@ -52,12 +37,12 @@ export default RepoFileButtons; role="group" aria-label="File actions"> <a - :href="activeFile.blame_path" + :href="activeFile.blamePath" class="btn btn-default blame"> Blame </a> <a - :href="activeFile.commits_path" + :href="activeFile.commitsPath" class="btn btn-default history"> History </a> @@ -67,13 +52,5 @@ export default RepoFileButtons; Permalink </a> </div> - - <a - v-if="canPreview" - href="#" - @click.prevent="rawPreviewToggle" - class="btn btn-default preview"> - {{activeFileLabel}} - </a> </div> </template> diff --git a/app/assets/javascripts/repo/components/repo_loading_file.vue b/app/assets/javascripts/repo/components/repo_loading_file.vue index 832b45b2b29..1e6c405f292 100644 --- a/app/assets/javascripts/repo/components/repo_loading_file.vue +++ b/app/assets/javascripts/repo/components/repo_loading_file.vue @@ -1,10 +1,12 @@ <script> - import repoMixin from '../mixins/repo_mixin'; + import { mapGetters } from 'vuex'; export default { - mixins: [ - repoMixin, - ], + computed: { + ...mapGetters([ + 'isCollapsed', + ]), + }, methods: { lineOfCode(n) { return `skeleton-line-${n}`; @@ -28,7 +30,7 @@ </div> </div> </td> - <template v-if="!isMini"> + <template v-if="!isCollapsed"> <td class="hidden-sm hidden-xs"> <div class="animation-container"> diff --git a/app/assets/javascripts/repo/components/repo_prev_directory.vue b/app/assets/javascripts/repo/components/repo_prev_directory.vue index c4bf6dcdec2..a2b305bbd05 100644 --- a/app/assets/javascripts/repo/components/repo_prev_directory.vue +++ b/app/assets/javascripts/repo/components/repo_prev_directory.vue @@ -1,26 +1,22 @@ <script> - import eventHub from '../event_hub'; - import repoMixin from '../mixins/repo_mixin'; + import { mapGetters, mapState, mapActions } from 'vuex'; export default { - mixins: [ - repoMixin, - ], - props: { - prevUrl: { - type: String, - required: true, - }, - }, computed: { + ...mapState([ + 'parentTreeUrl', + ]), + ...mapGetters([ + 'isCollapsed', + ]), colSpanCondition() { - return this.isMini ? undefined : 3; + return this.isCollapsed ? undefined : 3; }, }, methods: { - linkClicked(file) { - eventHub.$emit('goToPreviousDirectoryClicked', file); - }, + ...mapActions([ + 'getTreeData', + ]), }, }; </script> @@ -30,9 +26,9 @@ <td :colspan="colSpanCondition" class="table-cell" - @click.prevent="linkClicked(prevUrl)" + @click.prevent="getTreeData({ endpoint: parentTreeUrl })" > - <a :href="prevUrl">...</a> + <a :href="parentTreeUrl">...</a> </td> </tr> </template> diff --git a/app/assets/javascripts/repo/components/repo_preview.vue b/app/assets/javascripts/repo/components/repo_preview.vue index b5be771d539..d1883299bd9 100644 --- a/app/assets/javascripts/repo/components/repo_preview.vue +++ b/app/assets/javascripts/repo/components/repo_preview.vue @@ -1,26 +1,20 @@ <script> /* global LineHighlighter */ - -import Store from '../stores/repo_store'; +import { mapGetters } from 'vuex'; export default { - data() { - return Store; - }, computed: { - html() { - return this.activeFile.html; + ...mapGetters([ + 'activeFile', + ]), + renderErrorTooLarge() { + return this.activeFile.renderError === 'too_large'; }, }, methods: { highlightFile() { $(this.$el).find('.file-content').syntaxHighlight(); }, - highlightLine() { - if (Store.activeLine > -1) { - this.lineHighlighter.highlightHash(`#L${Store.activeLine}`); - } - }, }, mounted() { this.highlightFile(); @@ -29,38 +23,39 @@ export default { scrollFileHolder: true, }); }, - watch: { - html() { - this.$nextTick(() => { - this.highlightFile(); - this.highlightLine(); - }); - }, - activeLine() { - this.highlightLine(); - }, + updated() { + this.$nextTick(() => { + this.highlightFile(); + }); }, }; </script> <template> -<div> +<div class="blob-viewer-container"> <div - v-if="!activeFile.render_error" + v-if="!activeFile.renderError" v-html="activeFile.html"> </div> <div - v-else-if="activeFile.tooLarge" + v-else-if="activeFile.tempFile" + class="vertical-center render-error"> + <p class="text-center"> + The source could not be displayed for this temporary file. + </p> + </div> + <div + v-else-if="renderErrorTooLarge" class="vertical-center render-error"> <p class="text-center"> - The source could not be displayed because it is too large. You can <a :href="activeFile.raw_path">download</a> it instead. + The source could not be displayed because it is too large. You can <a :href="activeFile.rawPath" download>download</a> it instead. </p> </div> <div v-else class="vertical-center render-error"> <p class="text-center"> - The source could not be displayed because a rendering error occurred. You can <a :href="activeFile.raw_path">download</a> it instead. + The source could not be displayed because a rendering error occurred. You can <a :href="activeFile.rawPath" download>download</a> it instead. </p> </div> </div> diff --git a/app/assets/javascripts/repo/components/repo_sidebar.vue b/app/assets/javascripts/repo/components/repo_sidebar.vue index 09dc9ee25d7..63c0d70f5c0 100644 --- a/app/assets/javascripts/repo/components/repo_sidebar.vue +++ b/app/assets/javascripts/repo/components/repo_sidebar.vue @@ -1,120 +1,55 @@ <script> -import _ from 'underscore'; -import Service from '../services/repo_service'; -import Helper from '../helpers/repo_helper'; -import Store from '../stores/repo_store'; -import eventHub from '../event_hub'; +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'; -import RepoMixin from '../mixins/repo_mixin'; export default { - mixins: [RepoMixin], components: { 'repo-previous-directory': RepoPreviousDirectory, 'repo-file': RepoFile, 'repo-loading-file': RepoLoadingFile, }, created() { - window.addEventListener('popstate', this.checkHistory); + window.addEventListener('popstate', this.popHistoryState); }, destroyed() { - eventHub.$off('fileNameClicked', this.fileClicked); - eventHub.$off('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked); - window.removeEventListener('popstate', this.checkHistory); + window.removeEventListener('popstate', this.popHistoryState); }, mounted() { - eventHub.$on('fileNameClicked', this.fileClicked); - eventHub.$on('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked); - }, - data() { - return Store; + this.getTreeData(); }, computed: { - flattendFiles() { - const mapFiles = arr => (!arr.files.length ? [] : _.map(arr.files, a => [a, mapFiles(a)])); - - return _.chain(this.files) - .map(arr => [arr, mapFiles(arr)]) - .flatten() - .value(); - }, + ...mapState([ + 'loading', + 'isRoot', + ]), + ...mapState({ + projectName(state) { + return state.project.name; + }, + }), + ...mapGetters([ + 'treeList', + 'isCollapsed', + ]), }, methods: { - checkHistory() { - let selectedFile = this.files.find(file => location.pathname.indexOf(file.url) > -1); - if (!selectedFile) { - // Maybe it is not in the current tree but in the opened tabs - selectedFile = Helper.getFileFromPath(location.pathname); - } - - let lineNumber = null; - if (location.hash.indexOf('#L') > -1) lineNumber = Number(location.hash.substr(2)); - - if (selectedFile) { - if (selectedFile.url !== this.activeFile.url) { - this.fileClicked(selectedFile, lineNumber); - } else { - Store.setActiveLine(lineNumber); - } - } else { - // Not opened at all lets open new tab - this.fileClicked({ - url: location.href, - }, lineNumber); - } - }, - - fileClicked(clickedFile, lineNumber) { - const file = clickedFile; - - if (file.loading) return; - - if (file.type === 'tree' && file.opened) { - Helper.setDirectoryToClosed(file); - Store.setActiveLine(lineNumber); - } else if (file.type === 'submodule') { - file.loading = true; - - gl.utils.visitUrl(file.url); - } else { - const openFile = Helper.getFileFromPath(file.url); - - if (openFile) { - Store.setActiveFiles(openFile); - Store.setActiveLine(lineNumber); - } else { - file.loading = true; - Service.url = file.url; - Helper.getContent(file) - .then(() => { - file.loading = false; - Helper.scrollTabsRight(); - Store.setActiveLine(lineNumber); - }) - .catch(Helper.loadingError); - } - } - }, - - goToPreviousDirectoryClicked(prevURL) { - Service.url = prevURL; - Helper.getContent(null, true) - .then(() => Helper.scrollTabsRight()) - .catch(Helper.loadingError); - }, + ...mapActions([ + 'getTreeData', + 'popHistoryState', + ]), }, }; </script> <template> -<div id="sidebar" :class="{'sidebar-mini' : isMini}"> +<div id="sidebar" :class="{'sidebar-mini' : isCollapsed}"> <table class="table"> <thead> <tr> <th - v-if="isMini" + v-if="isCollapsed" class="repo-file-options title" > <strong class="clgray"> @@ -136,17 +71,16 @@ export default { </thead> <tbody> <repo-previous-directory - v-if="!isRoot && !loading.tree" - :prev-url="prevURL" + v-if="!isRoot && treeList.length" /> <repo-loading-file - v-if="!flattendFiles.length && loading.tree" + v-if="!treeList.length && loading" v-for="n in 5" :key="n" /> <repo-file - v-for="file in flattendFiles" - :key="file.id" + v-for="(file, index) in treeList" + :key="index" :file="file" /> </tbody> diff --git a/app/assets/javascripts/repo/components/repo_tab.vue b/app/assets/javascripts/repo/components/repo_tab.vue index 405d7b4cf86..da0714c368c 100644 --- a/app/assets/javascripts/repo/components/repo_tab.vue +++ b/app/assets/javascripts/repo/components/repo_tab.vue @@ -1,7 +1,7 @@ <script> -import Store from '../stores/repo_store'; +import { mapActions } from 'vuex'; -const RepoTab = { +export default { props: { tab: { type: Object, @@ -11,7 +11,7 @@ const RepoTab = { computed: { closeLabel() { - if (this.tab.changed) { + if (this.tab.changed || this.tab.tempFile) { return `${this.tab.name} changed`; } return `Close ${this.tab.name}`; @@ -26,29 +26,23 @@ const RepoTab = { }, methods: { - tabClicked(file) { - Store.setActiveFiles(file); - }, - closeTab(file) { - if (file.changed || file.tempFile) return; - - Store.removeFromOpenedFiles(file); - }, + ...mapActions([ + 'setFileActive', + 'closeFile', + ]), }, }; - -export default RepoTab; </script> <template> <li :class="{ active : tab.active }" - @click="tabClicked(tab)" + @click="setFileActive(tab)" > <button type="button" class="close-btn" - @click.stop.prevent="closeTab(tab)" + @click.stop.prevent="closeFile({ file: tab })" :aria-label="closeLabel"> <i class="fa" @@ -61,7 +55,7 @@ export default RepoTab; href="#" class="repo-tab" :title="tab.url" - @click.prevent="tabClicked(tab)"> + @click.prevent.stop="setFileActive(tab)"> {{tab.name}} </a> </li> diff --git a/app/assets/javascripts/repo/components/repo_tabs.vue b/app/assets/javascripts/repo/components/repo_tabs.vue index b57cd0960de..59beae53e8d 100644 --- a/app/assets/javascripts/repo/components/repo_tabs.vue +++ b/app/assets/javascripts/repo/components/repo_tabs.vue @@ -1,15 +1,15 @@ <script> - import Store from '../stores/repo_store'; + import { mapState } from 'vuex'; import RepoTab from './repo_tab.vue'; - import RepoMixin from '../mixins/repo_mixin'; export default { - mixins: [RepoMixin], components: { 'repo-tab': RepoTab, }, - data() { - return Store; + computed: { + ...mapState([ + 'openFiles', + ]), }, }; </script> @@ -20,7 +20,7 @@ class="list-unstyled" > <repo-tab - v-for="tab in openedFiles" + v-for="tab in openFiles" :key="tab.id" :tab="tab" /> diff --git a/app/assets/javascripts/repo/event_hub.js b/app/assets/javascripts/repo/event_hub.js deleted file mode 100644 index 0948c2e5352..00000000000 --- a/app/assets/javascripts/repo/event_hub.js +++ /dev/null @@ -1,3 +0,0 @@ -import Vue from 'vue'; - -export default new Vue(); diff --git a/app/assets/javascripts/repo/helpers/monaco_loader_helper.js b/app/assets/javascripts/repo/helpers/monaco_loader_helper.js deleted file mode 100644 index f8729bbf585..00000000000 --- a/app/assets/javascripts/repo/helpers/monaco_loader_helper.js +++ /dev/null @@ -1,25 +0,0 @@ -/* global monaco */ -import RepoEditor from '../components/repo_editor.vue'; -import Store from '../stores/repo_store'; -import Helper from '../helpers/repo_helper'; -import monacoLoader from '../monaco_loader'; - -function repoEditorLoader() { - Store.monacoLoading = true; - return new Promise((resolve, reject) => { - monacoLoader(['vs/editor/editor.main'], () => { - Helper.monaco = monaco; - Store.monacoLoading = false; - resolve(RepoEditor); - }, () => { - Store.monacoLoading = false; - reject(); - }); - }); -} - -const MonacoLoaderHelper = { - repoEditorLoader, -}; - -export default MonacoLoaderHelper; diff --git a/app/assets/javascripts/repo/helpers/repo_helper.js b/app/assets/javascripts/repo/helpers/repo_helper.js deleted file mode 100644 index fb26f3b7380..00000000000 --- a/app/assets/javascripts/repo/helpers/repo_helper.js +++ /dev/null @@ -1,317 +0,0 @@ -import Service from '../services/repo_service'; -import Store from '../stores/repo_store'; -import Flash from '../../flash'; - -const RepoHelper = { - monacoInstance: null, - - getDefaultActiveFile() { - return { - id: '', - active: true, - binary: false, - extension: '', - html: '', - mime_type: '', - name: '', - plain: '', - size: 0, - url: '', - raw: false, - newContent: '', - changed: false, - loading: false, - }; - }, - - key: '', - - Time: window.performance - && window.performance.now - ? window.performance - : Date, - - getFileExtension(fileName) { - return fileName.split('.').pop(); - }, - - getLanguageIDForFile(file, langs) { - const ext = RepoHelper.getFileExtension(file.name); - const foundLang = RepoHelper.findLanguage(ext, langs); - - return foundLang ? foundLang.id : 'plaintext'; - }, - - setMonacoModelFromLanguage() { - RepoHelper.monacoInstance.setModel(null); - const languages = RepoHelper.monaco.languages.getLanguages(); - const languageID = RepoHelper.getLanguageIDForFile(Store.activeFile, languages); - const newModel = RepoHelper.monaco.editor.createModel(Store.blobRaw, languageID); - RepoHelper.monacoInstance.setModel(newModel); - }, - - findLanguage(ext, langs) { - return langs.find(lang => lang.extensions && lang.extensions.indexOf(`.${ext}`) > -1); - }, - - setDirectoryOpen(tree, title) { - if (!tree) return; - - Object.assign(tree, { - opened: true, - }); - - RepoHelper.updateHistoryEntry(tree.url, title); - Store.path = tree.path; - }, - - setDirectoryToClosed(entry) { - Object.assign(entry, { - opened: false, - files: [], - }); - }, - - isRenderable() { - const okExts = ['md', 'svg']; - return okExts.indexOf(Store.activeFile.extension) > -1; - }, - - setBinaryDataAsBase64(file) { - Service.getBase64Content(file.raw_path) - .then((response) => { - Store.blobRaw = response; - file.base64 = response; // eslint-disable-line no-param-reassign - }) - .catch(RepoHelper.loadingError); - }, - - getContent(treeOrFile, emptyFiles = false) { - let file = treeOrFile; - - if (!Store.files.length) { - Store.loading.tree = true; - } - - return Service.getContent() - .then((response) => { - const data = response.data; - if (response.headers && response.headers['page-title']) data.pageTitle = decodeURI(response.headers['page-title']); - if (data.path && !Store.isInitialRoot) { - Store.isRoot = data.path === '/'; - Store.isInitialRoot = Store.isRoot; - } - - if (file && file.type === 'blob') { - if (!file) file = data; - Store.binary = data.binary; - - if (data.binary) { - // file might be undefined - RepoHelper.setBinaryDataAsBase64(data); - Store.setViewToPreview(); - } else if (!Store.isPreviewView() && !data.render_error) { - Service.getRaw(data) - .then((rawResponse) => { - Store.blobRaw = rawResponse.data; - data.plain = rawResponse.data; - RepoHelper.setFile(data, file); - }).catch(RepoHelper.loadingError); - } - - if (Store.isPreviewView()) { - RepoHelper.setFile(data, file); - } - } else { - Store.loading.tree = false; - RepoHelper.setDirectoryOpen(file, data.pageTitle || data.name); - - if (emptyFiles) { - Store.files = []; - } - - this.addToDirectory(file, data); - - Store.prevURL = Service.blobURLtoParentTree(Service.url); - } - }).catch(RepoHelper.loadingError); - }, - - addToDirectory(file, data) { - const tree = file || Store; - - // TODO: Figure out why `popstate` is being trigger in the specs - if (!tree.files) return; - - const files = tree.files.concat(this.dataToListOfFiles(data, file ? file.level + 1 : 0)); - - tree.files = files; - }, - - setFile(data, file) { - const newFile = data; - newFile.url = file.url || Service.url; // Grab the URL from service, happens on page refresh. - - if (newFile.render_error === 'too_large' || newFile.render_error === 'collapsed') { - newFile.tooLarge = true; - } - newFile.newContent = ''; - - Store.addToOpenedFiles(newFile); - Store.setActiveFiles(newFile); - }, - - serializeRepoEntity(type, entity, level = 0) { - const { - id, - url, - name, - icon, - last_commit, - tree_url, - path, - tempFile, - active, - opened, - } = entity; - - return { - id, - type, - name, - url, - tree_url, - path, - level, - tempFile, - icon: `fa-${icon}`, - files: [], - loading: false, - opened, - active, - // eslint-disable-next-line camelcase - lastCommit: last_commit ? { - url: `${Store.projectUrl}/commit/${last_commit.id}`, - message: last_commit.message, - updatedAt: last_commit.committed_date, - } : {}, - }; - }, - - scrollTabsRight() { - const tabs = document.getElementById('tabs'); - if (!tabs) return; - tabs.scrollLeft = tabs.scrollWidth; - }, - - dataToListOfFiles(data, level) { - const { blobs, trees, submodules } = data; - return [ - ...trees.map(tree => RepoHelper.serializeRepoEntity('tree', tree, level)), - ...submodules.map(submodule => RepoHelper.serializeRepoEntity('submodule', submodule, level)), - ...blobs.map(blob => RepoHelper.serializeRepoEntity('blob', blob, level)), - ]; - }, - - genKey() { - return RepoHelper.Time.now().toFixed(3); - }, - - updateHistoryEntry(url, title) { - const history = window.history; - - RepoHelper.key = RepoHelper.genKey(); - - if (document.location.pathname !== url) { - history.pushState({ key: RepoHelper.key }, '', url); - } - - if (title) { - document.title = title; - } - }, - - findOpenedFileFromActive() { - return Store.openedFiles.find(openedFile => Store.activeFile.id === openedFile.id); - }, - - getFileFromPath(path) { - return Store.openedFiles.find(file => file.url === path); - }, - - loadingError() { - Flash('Unable to load this content at this time.'); - }, - openEditMode() { - Store.editMode = true; - Store.currentBlobView = 'repo-editor'; - }, - updateStorePath(path) { - Store.path = path; - }, - findOrCreateEntry(type, tree, name) { - let exists = true; - let foundEntry = tree.files.find(dir => dir.type === type && dir.name === name); - - if (!foundEntry) { - foundEntry = RepoHelper.serializeRepoEntity(type, { - id: name, - name, - path: tree.path ? `${tree.path}/${name}` : name, - icon: type === 'tree' ? 'folder' : 'file-text-o', - tempFile: true, - opened: true, - active: true, - }, tree.level !== undefined ? tree.level + 1 : 0); - - exists = false; - tree.files.push(foundEntry); - } - - return { - entry: foundEntry, - exists, - }; - }, - removeAllTmpFiles(storeFilesKey) { - Store[storeFilesKey] = Store[storeFilesKey].filter(f => !f.tempFile); - }, - createNewEntry(name, type) { - const originalPath = Store.path; - let entryName = name; - - if (entryName.indexOf(`${originalPath}/`) !== 0) { - this.updateStorePath(''); - } else { - entryName = entryName.replace(`${originalPath}/`, ''); - } - - if (entryName === '') return; - - const fileName = type === 'tree' ? '.gitkeep' : entryName; - let tree = Store; - - if (type === 'tree') { - const dirNames = entryName.split('/'); - - dirNames.forEach((dirName) => { - if (dirName === '') return; - - tree = this.findOrCreateEntry('tree', tree, dirName).entry; - }); - } - - if ((type === 'tree' && tree.tempFile) || type === 'blob') { - const file = this.findOrCreateEntry('blob', tree, fileName); - - if (!file.exists) { - this.setFile(file.entry, file.entry); - this.openEditMode(); - } - } - - this.updateStorePath(originalPath); - }, -}; - -export default RepoHelper; diff --git a/app/assets/javascripts/repo/index.js b/app/assets/javascripts/repo/index.js index 72fc5a70648..b6801af7fcb 100644 --- a/app/assets/javascripts/repo/index.js +++ b/app/assets/javascripts/repo/index.js @@ -1,55 +1,50 @@ -import $ from 'jquery'; import Vue from 'vue'; +import { mapActions } from 'vuex'; import { convertPermissionToBoolean } from '../lib/utils/common_utils'; -import Service from './services/repo_service'; -import Store from './stores/repo_store'; 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 initDropdowns() { - $('.js-tree-ref-target-holder').hide(); -} - -function addEventsForNonVueEls() { - window.onbeforeunload = function confirmUnload(e) { - const hasChanged = Store.openedFiles - .some(file => file.changed); - if (!hasChanged) return undefined; - const event = e || window.event; - if (event) event.returnValue = 'Are you sure you want to lose unsaved changes?'; - // For Safari - return 'Are you sure you want to lose unsaved changes?'; - }; -} - -function setInitialStore(data) { - Store.service = Service; - Store.service.url = data.url; - Store.service.refsUrl = data.refsUrl; - Store.path = data.currentPath; - Store.projectId = data.projectId; - Store.projectName = data.projectName; - Store.projectUrl = data.projectUrl; - Store.canCommit = data.canCommit; - Store.onTopOfBranch = data.onTopOfBranch; - Store.newMrTemplateUrl = decodeURIComponent(data.newMrTemplateUrl); - Store.customBranchURL = decodeURIComponent(data.blobUrl); - Store.isRoot = convertPermissionToBoolean(data.root); - Store.isInitialRoot = convertPermissionToBoolean(data.root); - Store.currentBranch = $('button.dropdown-menu-toggle').attr('data-ref'); - Store.checkIsCommitable(); - Store.setBranchHash(); -} - 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'); }, @@ -59,15 +54,20 @@ function initRepo(el) { 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, }, @@ -87,32 +87,20 @@ function initNewBranchForm() { components: { newBranchForm, }, + store, render(createElement) { - return createElement('new-branch-form', { - props: { - currentBranch: Store.currentBranch, - }, - }); + return createElement('new-branch-form'); }, }); } -function initRepoBundle() { - const repo = document.getElementById('repo'); - const editButton = document.querySelector('.editable-mode'); - const newDropdownHolder = document.querySelector('.js-new-dropdown'); - setInitialStore(repo.dataset); - addEventsForNonVueEls(); - initDropdowns(); - - Vue.use(Translate); - - initRepo(repo); - initRepoEditButton(editButton); - initNewBranchForm(); - initNewDropdown(newDropdownHolder); -} +const repo = document.getElementById('repo'); +const editButton = document.querySelector('.editable-mode'); +const newDropdownHolder = document.querySelector('.js-new-dropdown'); -$(initRepoBundle); +Vue.use(Translate); -export default initRepoBundle; +initRepo(repo); +initRepoEditButton(editButton); +initNewBranchForm(); +initNewDropdown(newDropdownHolder); diff --git a/app/assets/javascripts/repo/mixins/repo_mixin.js b/app/assets/javascripts/repo/mixins/repo_mixin.js deleted file mode 100644 index efeda426b96..00000000000 --- a/app/assets/javascripts/repo/mixins/repo_mixin.js +++ /dev/null @@ -1,17 +0,0 @@ -import Store from '../stores/repo_store'; - -const RepoMixin = { - computed: { - isMini() { - return !!Store.openedFiles.length; - }, - - changedFiles() { - const changedFileList = this.openedFiles - .filter(file => file.changed || file.tempFile); - return changedFileList; - }, - }, -}; - -export default RepoMixin; diff --git a/app/assets/javascripts/repo/services/index.js b/app/assets/javascripts/repo/services/index.js new file mode 100644 index 00000000000..dc222ccac01 --- /dev/null +++ b/app/assets/javascripts/repo/services/index.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; +import Api from '../../api'; + +Vue.use(VueResource); + +export default { + getTreeData(endpoint) { + return Vue.http.get(endpoint, { params: { format: 'json' } }); + }, + getFileData(endpoint) { + return Vue.http.get(endpoint, { params: { format: 'json' } }); + }, + getRawFileData(file) { + if (file.tempFile) { + return Promise.resolve(file.content); + } + + return Vue.http.get(file.rawPath, { params: { format: 'json' } }) + .then(res => res.text()); + }, + getBranchData(projectId, currentBranch) { + return Api.branchSingle(projectId, currentBranch); + }, + createBranch(projectId, payload) { + const url = Api.buildUrl(Api.createBranchPath).replace(':id', projectId); + + return Vue.http.post(url, payload); + }, + commit(projectId, payload) { + return Api.commitMultiple(projectId, payload); + }, +}; diff --git a/app/assets/javascripts/repo/services/repo_service.js b/app/assets/javascripts/repo/services/repo_service.js deleted file mode 100644 index c9fa5cc8bf8..00000000000 --- a/app/assets/javascripts/repo/services/repo_service.js +++ /dev/null @@ -1,101 +0,0 @@ -import axios from 'axios'; -import csrf from '../../lib/utils/csrf'; -import Store from '../stores/repo_store'; -import Api from '../../api'; -import Helper from '../helpers/repo_helper'; - -axios.defaults.headers.common[csrf.headerKey] = csrf.token; - -const RepoService = { - url: '', - options: { - params: { - format: 'json', - }, - }, - createBranchPath: '/api/:version/projects/:id/repository/branches', - richExtensionRegExp: /md/, - - getRaw(file) { - if (file.tempFile) { - return Promise.resolve({ - data: '', - }); - } - - return axios.get(file.raw_path, { - // Stop Axios from parsing a JSON file into a JS object - transformResponse: [res => res], - }); - }, - - buildParams(url = this.url) { - // shallow clone object without reference - const params = Object.assign({}, this.options.params); - - if (this.urlIsRichBlob(url)) params.viewer = 'rich'; - - return params; - }, - - urlIsRichBlob(url = this.url) { - const extension = Helper.getFileExtension(url); - - return this.richExtensionRegExp.test(extension); - }, - - getContent(url = this.url) { - const params = this.buildParams(url); - - return axios.get(url, { - params, - }); - }, - - getBase64Content(url = this.url) { - const request = axios.get(url, { - responseType: 'arraybuffer', - }); - - return request.then(response => this.bufferToBase64(response.data)); - }, - - bufferToBase64(data) { - return new Buffer(data, 'binary').toString('base64'); - }, - - blobURLtoParentTree(url) { - const urlArray = url.split('/'); - urlArray.pop(); - const blobIndex = urlArray.lastIndexOf('blob'); - - if (blobIndex > -1) urlArray[blobIndex] = 'tree'; - - return urlArray.join('/'); - }, - - getBranch() { - return Api.branchSingle(Store.projectId, Store.currentBranch); - }, - - commitFiles(payload) { - return Api.commitMultiple(Store.projectId, payload) - .then(this.commitFlash); - }, - - createBranch(payload) { - const url = Api.buildUrl(this.createBranchPath) - .replace(':id', Store.projectId); - return axios.post(url, payload); - }, - - commitFlash(data) { - if (data.short_id && data.stats) { - window.Flash(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice'); - } else { - window.Flash(data.message); - } - }, -}; - -export default RepoService; diff --git a/app/assets/javascripts/repo/stores/actions.js b/app/assets/javascripts/repo/stores/actions.js new file mode 100644 index 00000000000..ca2f2a5ce7a --- /dev/null +++ b/app/assets/javascripts/repo/stores/actions.js @@ -0,0 +1,129 @@ +import Vue from 'vue'; +import flash from '../../flash'; +import service from '../services'; +import * as types from './mutation_types'; + +export const redirectToUrl = url => gl.utils.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 }, { payload, newMr }) => + service.commit(state.project.id, payload) + .then((data) => { + const { branch } = payload; + if (!data.short_id) { + flash(data.message); + return; + } + + flash(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice'); + + if (newMr) { + redirectToUrl(`${state.endpoints.newMergeRequestUrl}${branch}`); + } else { + commit(types.SET_COMMIT_REF, data.id); + 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 new file mode 100644 index 00000000000..b81a70dfd1e --- /dev/null +++ b/app/assets/javascripts/repo/stores/actions/branch.js @@ -0,0 +1,20 @@ +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 = ({ rootState, commit }, branch) => service.createBranch( + rootState.project.id, + { + branch, + ref: rootState.currentBranch, + }, +).then(res => res.json()) +.then((data) => { + const branchName = data.name; + const url = location.href.replace(rootState.currentBranch, branchName); + + pushState(url); + + commit(types.SET_CURRENT_BRANCH, branchName); +}); diff --git a/app/assets/javascripts/repo/stores/actions/file.js b/app/assets/javascripts/repo/stores/actions/file.js new file mode 100644 index 00000000000..afbe0b78a82 --- /dev/null +++ b/app/assets/javascripts/repo/stores/actions/file.js @@ -0,0 +1,108 @@ +import { normalizeHeaders } from '../../../lib/utils/common_utils'; +import flash from '../../../flash'; +import service from '../../services'; +import * as types from '../mutation_types'; +import { + findEntry, + pushState, + setPageTitle, + createTemp, + findIndexOfFile, +} from '../utils'; + +export const closeFile = ({ commit, state, dispatch }, { file, force = false }) => { + if ((file.changed || file.tempFile) && !force) return; + + const indexOfClosedFile = findIndexOfFile(state.openFiles, file); + const fileWasActive = file.active; + + commit(types.TOGGLE_FILE_OPEN, file); + commit(types.SET_FILE_ACTIVE, { file, active: false }); + + if (state.openFiles.length > 0 && fileWasActive) { + const nextIndexToOpen = indexOfClosedFile === 0 ? 0 : indexOfClosedFile - 1; + const nextFileToOpen = state.openFiles[nextIndexToOpen]; + + dispatch('setFileActive', nextFileToOpen); + } else if (!state.openFiles.length) { + pushState(file.parentTreeUrl); + } +}; + +export const setFileActive = ({ commit, state, getters, dispatch }, file) => { + const currentActiveFile = getters.activeFile; + + if (file.active) return; + + if (currentActiveFile) { + commit(types.SET_FILE_ACTIVE, { file: currentActiveFile, active: false }); + } + + commit(types.SET_FILE_ACTIVE, { file, active: true }); + dispatch('scrollToTab'); + + // reset hash for line highlighting + location.hash = ''; +}; + +export const getFileData = ({ state, commit, dispatch }, file) => { + commit(types.TOGGLE_LOADING, file); + + service.getFileData(file.url) + .then((res) => { + const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']); + + setPageTitle(pageTitle); + + return res.json(); + }) + .then((data) => { + commit(types.SET_FILE_DATA, { data, file }); + commit(types.TOGGLE_FILE_OPEN, file); + dispatch('setFileActive', file); + commit(types.TOGGLE_LOADING, file); + + pushState(file.url); + }) + .catch(() => { + commit(types.TOGGLE_LOADING, file); + flash('Error loading file data. Please try again.'); + }); +}; + +export const getRawFileData = ({ commit, dispatch }, file) => service.getRawFileData(file) + .then((raw) => { + commit(types.SET_FILE_RAW_DATA, { file, raw }); + }) + .catch(() => flash('Error loading file content. Please try again.')); + +export const changeFileContent = ({ commit }, { file, content }) => { + commit(types.UPDATE_FILE_CONTENT, { file, content }); +}; + +export const createTempFile = ({ state, commit, dispatch }, { tree, name, content = '', base64 = '' }) => { + const file = createTemp({ + name: name.replace(`${state.path}/`, ''), + path: tree.path, + type: 'blob', + level: tree.level !== undefined ? tree.level + 1 : 0, + changed: true, + content, + base64, + }); + + if (findEntry(tree, 'blob', file.name)) return flash(`The name "${file.name}" is already taken in this directory.`); + + commit(types.CREATE_TMP_FILE, { + parent: tree, + file, + }); + commit(types.TOGGLE_FILE_OPEN, file); + dispatch('setFileActive', file); + + if (!state.editMode && !file.base64) { + dispatch('toggleEditMode', true); + } + + return Promise.resolve(file); +}; diff --git a/app/assets/javascripts/repo/stores/actions/tree.js b/app/assets/javascripts/repo/stores/actions/tree.js new file mode 100644 index 00000000000..129743c66c2 --- /dev/null +++ b/app/assets/javascripts/repo/stores/actions/tree.js @@ -0,0 +1,110 @@ +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, +} from '../utils'; + +export const getTreeData = ( + { commit, state }, + { 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) => { + if (!state.isInitialRoot) { + commit(types.SET_ROOT, data.path === '/'); + } + + commit(types.SET_DIRECTORY_DATA, { data, tree }); + commit(types.SET_PARENT_TREE_URL, data.parent_tree_url); + commit(types.TOGGLE_LOADING, 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); + commit(types.SET_DIRECTORY_DATA, { 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); + + gl.utils.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', + }); + } +}; diff --git a/app/assets/javascripts/repo/stores/getters.js b/app/assets/javascripts/repo/stores/getters.js new file mode 100644 index 00000000000..1ed05ac6e35 --- /dev/null +++ b/app/assets/javascripts/repo/stores/getters.js @@ -0,0 +1,36 @@ +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); +}; diff --git a/app/assets/javascripts/repo/stores/index.js b/app/assets/javascripts/repo/stores/index.js new file mode 100644 index 00000000000..6ac9bfd8189 --- /dev/null +++ b/app/assets/javascripts/repo/stores/index.js @@ -0,0 +1,15 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import state from './state'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + state: state(), + actions, + mutations, + getters, +}); diff --git a/app/assets/javascripts/repo/stores/mutation_types.js b/app/assets/javascripts/repo/stores/mutation_types.js new file mode 100644 index 00000000000..4722a7dd0df --- /dev/null +++ b/app/assets/javascripts/repo/stores/mutation_types.js @@ -0,0 +1,28 @@ +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'; + +// 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'; + +// File mutation types +export const SET_FILE_DATA = 'SET_FILE_DATA'; +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 DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES'; +export const CREATE_TMP_FILE = 'CREATE_TMP_FILE'; + +// Viewer mutation types +export const SET_PREVIEW_MODE = 'SET_PREVIEW_MODE'; +export const SET_EDIT_MODE = 'SET_EDIT_MODE'; +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/repo/stores/mutations.js new file mode 100644 index 00000000000..2f9b038322b --- /dev/null +++ b/app/assets/javascripts/repo/stores/mutations.js @@ -0,0 +1,54 @@ +import * as types from './mutation_types'; +import fileMutations from './mutations/file'; +import treeMutations from './mutations/tree'; +import branchMutations from './mutations/branch'; + +export default { + [types.SET_INITIAL_DATA](state, data) { + Object.assign(state, data); + }, + [types.SET_PREVIEW_MODE](state) { + Object.assign(state, { + currentBlobView: 'repo-preview', + }); + }, + [types.SET_EDIT_MODE](state) { + Object.assign(state, { + currentBlobView: 'repo-editor', + }); + }, + [types.TOGGLE_LOADING](state, entry) { + Object.assign(entry, { + loading: !entry.loading, + }); + }, + [types.TOGGLE_EDIT_MODE](state) { + Object.assign(state, { + editMode: !state.editMode, + }); + }, + [types.TOGGLE_DISCARD_POPUP](state, discardPopupOpen) { + Object.assign(state, { + 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) { + Object.assign(state, { + previousUrl, + }); + }, + ...fileMutations, + ...treeMutations, + ...branchMutations, +}; diff --git a/app/assets/javascripts/repo/stores/mutations/branch.js b/app/assets/javascripts/repo/stores/mutations/branch.js new file mode 100644 index 00000000000..d8229e8a620 --- /dev/null +++ b/app/assets/javascripts/repo/stores/mutations/branch.js @@ -0,0 +1,9 @@ +import * as types from '../mutation_types'; + +export default { + [types.SET_CURRENT_BRANCH](state, currentBranch) { + Object.assign(state, { + currentBranch, + }); + }, +}; diff --git a/app/assets/javascripts/repo/stores/mutations/file.js b/app/assets/javascripts/repo/stores/mutations/file.js new file mode 100644 index 00000000000..f9ba80b9dc2 --- /dev/null +++ b/app/assets/javascripts/repo/stores/mutations/file.js @@ -0,0 +1,54 @@ +import * as types from '../mutation_types'; +import { findIndexOfFile } from '../utils'; + +export default { + [types.SET_FILE_ACTIVE](state, { file, active }) { + Object.assign(file, { + active, + }); + }, + [types.TOGGLE_FILE_OPEN](state, file) { + Object.assign(file, { + opened: !file.opened, + }); + + if (file.opened) { + state.openFiles.push(file); + } else { + state.openFiles.splice(findIndexOfFile(state.openFiles, file), 1); + } + }, + [types.SET_FILE_DATA](state, { data, file }) { + Object.assign(file, { + blamePath: data.blame_path, + commitsPath: data.commits_path, + permalink: data.permalink, + rawPath: data.raw_path, + binary: data.binary, + html: data.html, + renderError: data.render_error, + }); + }, + [types.SET_FILE_RAW_DATA](state, { file, raw }) { + Object.assign(file, { + raw, + }); + }, + [types.UPDATE_FILE_CONTENT](state, { file, content }) { + const changed = content !== file.raw; + + Object.assign(file, { + content, + changed, + }); + }, + [types.DISCARD_FILE_CHANGES](state, file) { + Object.assign(file, { + content: '', + changed: false, + }); + }, + [types.CREATE_TMP_FILE](state, { file, parent }) { + parent.tree.push(file); + }, +}; diff --git a/app/assets/javascripts/repo/stores/mutations/tree.js b/app/assets/javascripts/repo/stores/mutations/tree.js new file mode 100644 index 00000000000..52be2673107 --- /dev/null +++ b/app/assets/javascripts/repo/stores/mutations/tree.js @@ -0,0 +1,45 @@ +import * as types from '../mutation_types'; +import * as utils from '../utils'; + +export default { + [types.TOGGLE_TREE_OPEN](state, tree) { + Object.assign(tree, { + opened: !tree.opened, + }); + }, + [types.SET_DIRECTORY_DATA](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; + + Object.assign(tree, { + tree: [ + ...data.trees.map(t => utils.decorateData({ + ...t, + type: 'tree', + parentTreeUrl, + level, + }, state.project.url)), + ...data.submodules.map(m => utils.decorateData({ + ...m, + type: 'submodule', + parentTreeUrl, + level, + }, state.project.url)), + ...data.blobs.map(b => utils.decorateData({ + ...b, + type: 'blob', + parentTreeUrl, + level, + }, state.project.url)), + ], + }); + }, + [types.SET_PARENT_TREE_URL](state, url) { + Object.assign(state, { + parentTreeUrl: url, + }); + }, + [types.CREATE_TMP_TREE](state, { parent, tmpEntry }) { + parent.tree.push(tmpEntry); + }, +}; diff --git a/app/assets/javascripts/repo/stores/repo_store.js b/app/assets/javascripts/repo/stores/repo_store.js deleted file mode 100644 index 38df1e3e0d2..00000000000 --- a/app/assets/javascripts/repo/stores/repo_store.js +++ /dev/null @@ -1,189 +0,0 @@ -import Helper from '../helpers/repo_helper'; -import Service from '../services/repo_service'; - -const RepoStore = { - monacoLoading: false, - service: '', - canCommit: false, - onTopOfBranch: false, - editMode: false, - isRoot: null, - isInitialRoot: null, - prevURL: '', - projectId: '', - projectName: '', - projectUrl: '', - branchUrl: '', - blobRaw: '', - currentBlobView: 'repo-preview', - openedFiles: [], - submitCommitsLoading: false, - dialog: { - open: false, - title: '', - status: false, - }, - showNewBranchDialog: false, - activeFile: Helper.getDefaultActiveFile(), - activeFileIndex: 0, - activeLine: -1, - activeFileLabel: 'Raw', - files: [], - isCommitable: false, - binary: false, - currentBranch: '', - startNewMR: false, - currentHash: '', - currentShortHash: '', - customBranchURL: '', - newMrTemplateUrl: '', - branchChanged: false, - commitMessage: '', - path: '', - loading: { - tree: false, - blob: false, - }, - - setBranchHash() { - return Service.getBranch() - .then((data) => { - if (RepoStore.currentHash !== '' && data.commit.id !== RepoStore.currentHash) { - RepoStore.branchChanged = true; - } - RepoStore.currentHash = data.commit.id; - RepoStore.currentShortHash = data.commit.short_id; - }); - }, - - // mutations - checkIsCommitable() { - RepoStore.isCommitable = RepoStore.onTopOfBranch && RepoStore.canCommit; - }, - - toggleRawPreview() { - RepoStore.activeFile.raw = !RepoStore.activeFile.raw; - RepoStore.activeFileLabel = RepoStore.activeFile.raw ? 'Display rendered file' : 'Display source'; - }, - - setActiveFiles(file) { - if (RepoStore.isActiveFile(file)) return; - RepoStore.openedFiles = RepoStore.openedFiles - .map((openedFile, i) => RepoStore.setFileActivity(file, openedFile, i)); - - RepoStore.setActiveToRaw(); - - if (file.binary) { - RepoStore.blobRaw = file.base64; - } else if (file.newContent || file.plain) { - RepoStore.blobRaw = file.newContent || file.plain; - } else { - Service.getRaw(file) - .then((rawResponse) => { - RepoStore.blobRaw = rawResponse.data; - Helper.findOpenedFileFromActive().plain = rawResponse.data; - }).catch(Helper.loadingError); - } - - if (!file.loading && !file.tempFile) { - Helper.updateHistoryEntry(file.url, file.pageTitle || file.name); - } - RepoStore.binary = file.binary; - RepoStore.setActiveLine(-1); - }, - - setFileActivity(file, openedFile, i) { - const activeFile = openedFile; - activeFile.active = file.id === activeFile.id; - - if (activeFile.active) RepoStore.setActiveFile(activeFile, i); - - return activeFile; - }, - - setActiveFile(activeFile, i) { - RepoStore.activeFile = Object.assign({}, Helper.getDefaultActiveFile(), activeFile); - RepoStore.activeFileIndex = i; - }, - - setActiveLine(activeLine) { - if (!isNaN(activeLine)) RepoStore.activeLine = activeLine; - }, - - setActiveToRaw() { - RepoStore.activeFile.raw = false; - // can't get vue to listen to raw for some reason so RepoStore for now. - RepoStore.activeFileLabel = 'Display source'; - }, - - removeFromOpenedFiles(file) { - if (file.type === 'tree') return; - let foundIndex; - RepoStore.openedFiles = RepoStore.openedFiles.filter((openedFile, i) => { - if (openedFile.path === file.path) foundIndex = i; - return openedFile.path !== file.path; - }); - - // remove the file from the sidebar if it is a tempFile - if (file.tempFile) { - RepoStore.files = RepoStore.files.filter(f => !(f.tempFile && f.path === file.path)); - } - - // now activate the right tab based on what you closed. - if (RepoStore.openedFiles.length === 0) { - RepoStore.activeFile = {}; - return; - } - - if (RepoStore.openedFiles.length === 1 || foundIndex === 0) { - RepoStore.setActiveFiles(RepoStore.openedFiles[0]); - return; - } - - if (foundIndex && foundIndex > 0) { - RepoStore.setActiveFiles(RepoStore.openedFiles[foundIndex - 1]); - } - }, - - addToOpenedFiles(file) { - const openFile = file; - - const openedFilesAlreadyExists = RepoStore.openedFiles - .some(openedFile => openedFile.path === openFile.path); - - if (openedFilesAlreadyExists) return; - - openFile.changed = false; - openFile.active = true; - RepoStore.openedFiles.push(openFile); - }, - - setActiveFileContents(contents) { - if (!RepoStore.editMode) return; - const currentFile = RepoStore.openedFiles[RepoStore.activeFileIndex]; - RepoStore.activeFile.newContent = contents; - RepoStore.activeFile.changed = RepoStore.activeFile.plain !== RepoStore.activeFile.newContent; - currentFile.changed = RepoStore.activeFile.changed; - currentFile.newContent = contents; - }, - - toggleBlobView() { - RepoStore.currentBlobView = RepoStore.isPreviewView() ? 'repo-editor' : 'repo-preview'; - }, - - setViewToPreview() { - RepoStore.currentBlobView = 'repo-preview'; - }, - - // getters - - isActiveFile(file) { - return file && file.id === RepoStore.activeFile.id; - }, - - isPreviewView() { - return RepoStore.currentBlobView === 'repo-preview'; - }, -}; - -export default RepoStore; diff --git a/app/assets/javascripts/repo/stores/state.js b/app/assets/javascripts/repo/stores/state.js new file mode 100644 index 00000000000..aab74754f02 --- /dev/null +++ b/app/assets/javascripts/repo/stores/state.js @@ -0,0 +1,23 @@ +export default () => ({ + canCommit: false, + currentBranch: '', + currentBlobView: 'repo-preview', + currentRef: '', + discardPopupOpen: false, + editMode: false, + endpoints: {}, + isRoot: false, + isInitialRoot: false, + loading: false, + onTopOfBranch: false, + openFiles: [], + path: '', + project: { + id: 0, + name: '', + url: '', + }, + parentTreeUrl: '', + previousUrl: '', + tree: [], +}); diff --git a/app/assets/javascripts/repo/stores/utils.js b/app/assets/javascripts/repo/stores/utils.js new file mode 100644 index 00000000000..797c2b1e5b9 --- /dev/null +++ b/app/assets/javascripts/repo/stores/utils.js @@ -0,0 +1,108 @@ +export const dataStructure = () => ({ + id: '', + type: '', + name: '', + url: '', + path: '', + level: 0, + tempFile: false, + icon: '', + tree: [], + loading: false, + opened: false, + active: false, + changed: false, + lastCommit: {}, + tree_url: '', + blamePath: '', + commitsPath: '', + permalink: '', + rawPath: '', + binary: false, + html: '', + raw: '', + content: '', + parentTreeUrl: '', + renderError: false, + base64: false, +}); + +export const decorateData = (entity, projectUrl = '') => { + const { + id, + type, + url, + name, + icon, + last_commit, + tree_url, + path, + renderError, + content = '', + tempFile = false, + active = false, + opened = false, + changed = false, + parentTreeUrl = '', + level = 0, + base64 = false, + } = entity; + + return { + ...dataStructure(), + id, + type, + name, + url, + tree_url, + path, + level, + tempFile, + icon: `fa-${icon}`, + opened, + active, + parentTreeUrl, + changed, + renderError, + content, + base64, + // eslint-disable-next-line camelcase + lastCommit: last_commit ? { + url: `${projectUrl}/commit/${last_commit.id}`, + message: last_commit.message, + updatedAt: last_commit.committed_date, + } : {}, + }; +}; + +export const findEntry = (state, type, name) => state.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 }) => { + const treePath = path ? `${path}/${name}` : name; + + return decorateData({ + id: new Date().getTime().toString(), + name, + type, + tempFile: true, + path: treePath, + icon: type === 'tree' ? 'folder' : 'file-text-o', + changed, + content, + parentTreeUrl: '', + level, + base64, + renderError: base64, + }); +}; diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue new file mode 100644 index 00000000000..b8510a6ce3a --- /dev/null +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -0,0 +1,125 @@ +<script> +import { __, n__, sprintf } from '../../../locale'; +import loadingIcon from '../../../vue_shared/components/loading_icon.vue'; +import userAvatarImage from '../../../vue_shared/components/user_avatar/user_avatar_image.vue'; + +export default { + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + participants: { + type: Array, + required: false, + default: () => [], + }, + numberOfLessParticipants: { + type: Number, + required: false, + default: 7, + }, + }, + data() { + return { + isShowingMoreParticipants: false, + }; + }, + components: { + loadingIcon, + userAvatarImage, + }, + computed: { + lessParticipants() { + return this.participants.slice(0, this.numberOfLessParticipants); + }, + visibleParticipants() { + return this.isShowingMoreParticipants ? this.participants : this.lessParticipants; + }, + hasMoreParticipants() { + return this.participants.length > this.numberOfLessParticipants; + }, + toggleLabel() { + let label = ''; + if (this.isShowingMoreParticipants) { + label = __('- show less'); + } else { + label = sprintf(__('+ %{moreCount} more'), { + moreCount: this.participants.length - this.numberOfLessParticipants, + }); + } + + return label; + }, + participantLabel() { + return sprintf( + n__('%{count} participant', '%{count} participants', this.participants.length), + { count: this.loading ? '' : this.participantCount }, + ); + }, + participantCount() { + return this.participants.length; + }, + }, + methods: { + toggleMoreParticipants() { + this.isShowingMoreParticipants = !this.isShowingMoreParticipants; + }, + }, +}; +</script> + +<template> + <div> + <div class="sidebar-collapsed-icon"> + <i + class="fa fa-users" + aria-hidden="true"> + </i> + <loading-icon + v-if="loading" + class="js-participants-collapsed-loading-icon" /> + <span + v-else + class="js-participants-collapsed-count"> + {{ participantCount }} + </span> + </div> + <div class="title hide-collapsed"> + <loading-icon + v-if="loading" + :inline="true" + class="js-participants-expanded-loading-icon" /> + {{ participantLabel }} + </div> + <div class="participants-list hide-collapsed"> + <div + v-for="participant in visibleParticipants" + :key="participant.id" + class="participants-author js-participants-author"> + <a + class="author_link" + :href="participant.web_url"> + <user-avatar-image + :lazy="true" + :img-src="participant.avatar_url" + css-classes="avatar-inline" + :size="24" + :tooltip-text="participant.name" + tooltip-placement="bottom" /> + </a> + </div> + </div> + <div + v-if="hasMoreParticipants" + class="participants-more hide-collapsed"> + <button + type="button" + class="btn-transparent btn-blank js-toggle-participants-button" + @click="toggleMoreParticipants"> + {{ toggleLabel }} + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue b/app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue new file mode 100644 index 00000000000..c1296b28db7 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue @@ -0,0 +1,26 @@ +<script> +import Store from '../../stores/sidebar_store'; +import Mediator from '../../sidebar_mediator'; +import participants from './participants.vue'; + +export default { + data() { + return { + mediator: new Mediator(), + store: new Store(), + }; + }, + components: { + participants, + }, +}; +</script> + +<template> + <div class="block participants"> + <participants + :loading="store.isFetching.participants" + :participants="store.participants" + :number-of-less-participants="7" /> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue new file mode 100644 index 00000000000..4ad3d469f25 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue @@ -0,0 +1,45 @@ +<script> +import Store from '../../stores/sidebar_store'; +import Mediator from '../../sidebar_mediator'; +import eventHub from '../../event_hub'; +import Flash from '../../../flash'; +import subscriptions from './subscriptions.vue'; + +export default { + data() { + return { + mediator: new Mediator(), + store: new Store(), + }; + }, + + components: { + subscriptions, + }, + + methods: { + onToggleSubscription() { + this.mediator.toggleSubscription() + .catch(() => { + Flash('Error occurred when toggling the notification subscription'); + }); + }, + }, + + created() { + eventHub.$on('toggleSubscription', this.onToggleSubscription); + }, + + beforeDestroy() { + eventHub.$off('toggleSubscription', this.onToggleSubscription); + }, +}; +</script> + +<template> + <div class="block subscriptions"> + <subscriptions + :loading="store.isFetching.subscriptions" + :subscribed="store.subscribed" /> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue new file mode 100644 index 00000000000..a3a8213d63a --- /dev/null +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue @@ -0,0 +1,60 @@ +<script> +import { __ } from '../../../locale'; +import eventHub from '../../event_hub'; +import loadingButton from '../../../vue_shared/components/loading_button.vue'; + +export default { + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + subscribed: { + type: Boolean, + required: false, + }, + }, + components: { + loadingButton, + }, + computed: { + buttonLabel() { + let label; + if (this.subscribed === false) { + label = __('Subscribe'); + } else if (this.subscribed === true) { + label = __('Unsubscribe'); + } + + return label; + }, + }, + methods: { + toggleSubscription() { + eventHub.$emit('toggleSubscription'); + }, + }, +}; +</script> + +<template> + <div> + <div class="sidebar-collapsed-icon"> + <i + class="fa fa-rss" + aria-hidden="true"> + </i> + </div> + <span class="issuable-header-text hide-collapsed pull-left"> + {{ __('Notifications') }} + </span> + <loading-button + ref="loadingButton" + class="btn btn-default pull-right hide-collapsed js-issuable-subscribe-button" + :loading="loading" + :label="buttonLabel" + @click="toggleSubscription" + /> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js index 604648407a4..37c97225bfd 100644 --- a/app/assets/javascripts/sidebar/services/sidebar_service.js +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -7,6 +7,7 @@ export default class SidebarService { constructor(endpointMap) { if (!SidebarService.singleton) { this.endpoint = endpointMap.endpoint; + this.toggleSubscriptionEndpoint = endpointMap.toggleSubscriptionEndpoint; this.moveIssueEndpoint = endpointMap.moveIssueEndpoint; this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint; @@ -36,6 +37,10 @@ export default class SidebarService { }); } + toggleSubscription() { + return Vue.http.post(this.toggleSubscriptionEndpoint); + } + moveIssue(moveToProjectId) { return Vue.http.post(this.moveIssueEndpoint, { move_to_project_id: moveToProjectId, diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js index 09b9d75c02d..2650bb725d4 100644 --- a/app/assets/javascripts/sidebar/sidebar_bundle.js +++ b/app/assets/javascripts/sidebar/sidebar_bundle.js @@ -4,6 +4,8 @@ import SidebarAssignees from './components/assignees/sidebar_assignees'; import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue'; import SidebarMoveIssue from './lib/sidebar_move_issue'; import LockIssueSidebar from './components/lock/lock_issue_sidebar.vue'; +import sidebarParticipants from './components/participants/sidebar_participants.vue'; +import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue'; import Translate from '../vue_shared/translate'; import Mediator from './sidebar_mediator'; @@ -49,6 +51,36 @@ function mountLockComponent(mediator) { }).$mount(el); } +function mountParticipantsComponent() { + const el = document.querySelector('.js-sidebar-participants-entry-point'); + + if (!el) return; + + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + sidebarParticipants, + }, + render: createElement => createElement('sidebar-participants', {}), + }); +} + +function mountSubscriptionsComponent() { + const el = document.querySelector('.js-sidebar-subscriptions-entry-point'); + + if (!el) return; + + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + sidebarSubscriptions, + }, + render: createElement => createElement('sidebar-subscriptions', {}), + }); +} + function domContentLoaded() { const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML); const mediator = new Mediator(sidebarOptions); @@ -63,6 +95,8 @@ function domContentLoaded() { mountConfidentialComponent(mediator); mountLockComponent(mediator); + mountParticipantsComponent(); + mountSubscriptionsComponent(); new SidebarMoveIssue( mediator, diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index ede3a0de144..2bda5a47791 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -8,6 +8,7 @@ export default class SidebarMediator { this.store = new Store(options); this.service = new Service({ endpoint: options.endpoint, + toggleSubscriptionEndpoint: options.toggleSubscriptionEndpoint, moveIssueEndpoint: options.moveIssueEndpoint, projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint, }); @@ -39,10 +40,25 @@ export default class SidebarMediator { .then((data) => { this.store.setAssigneeData(data); this.store.setTimeTrackingData(data); + this.store.setParticipantsData(data); + this.store.setSubscriptionsData(data); }) .catch(() => new Flash('Error occurred when fetching sidebar data')); } + toggleSubscription() { + this.store.setFetchingState('subscriptions', true); + return this.service.toggleSubscription() + .then(() => { + this.store.setSubscribedState(!this.store.subscribed); + this.store.setFetchingState('subscriptions', false); + }) + .catch((err) => { + this.store.setFetchingState('subscriptions', false); + throw err; + }); + } + fetchAutocompleteProjects(searchTerm) { return this.service.getProjectsAutocomplete(searchTerm) .then(response => response.json()) diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index d5d04103f3f..3150221b685 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -12,10 +12,14 @@ export default class SidebarStore { this.assignees = []; this.isFetching = { assignees: true, + participants: true, + subscriptions: true, }; this.autocompleteProjects = []; this.moveToProjectId = 0; this.isLockDialogOpen = false; + this.participants = []; + this.subscribed = null; SidebarStore.singleton = this; } @@ -37,6 +41,20 @@ export default class SidebarStore { this.humanTotalTimeSpent = data.human_total_time_spent; } + setParticipantsData(data) { + this.isFetching.participants = false; + this.participants = data.participants || []; + } + + setSubscriptionsData(data) { + this.isFetching.subscriptions = false; + this.subscribed = data.subscribed || false; + } + + setFetchingState(key, value) { + this.isFetching[key] = value; + } + addAssignee(assignee) { if (!this.findAssignee(assignee)) { this.assignees.push(assignee); @@ -61,6 +79,10 @@ export default class SidebarStore { this.autocompleteProjects = projects; } + setSubscribedState(subscribed) { + this.subscribed = subscribed; + } + setMoveToProjectId(moveToProjectId) { this.moveToProjectId = moveToProjectId; } diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 759cc9925f4..a0883b32593 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -6,7 +6,7 @@ import _ from 'underscore'; // TODO: remove eventHub hack after code splitting refactor window.emitSidebarEvent = window.emitSidebarEvent || $.noop; -function UsersSelect(currentUser, els, options = {}) { +function UsersSelect(currentUser, els) { var $els; this.users = this.users.bind(this); this.user = this.user.bind(this); @@ -20,8 +20,6 @@ function UsersSelect(currentUser, els, options = {}) { } } - const { handleClick } = options; - $els = $(els); if (!els) { @@ -444,9 +442,6 @@ function UsersSelect(currentUser, els, options = {}) { } if ($el.closest('.add-issues-modal').length) { gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id; - } else if (handleClick) { - e.preventDefault(); - handleClick(user, isMarking); } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { return Issuable.filterResults($dropdown.closest('form')); } else if ($dropdown.hasClass('js-filter-submit')) { 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 79c3d335679..99f5c305df5 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 @@ -11,7 +11,7 @@ export default class MRWidgetService { this.removeWIPResource = Vue.resource(endpoints.removeWIPPath); this.removeSourceBranchResource = Vue.resource(endpoints.sourceBranchPath); this.deploymentsResource = Vue.resource(endpoints.ciEnvironmentsStatusPath); - this.pollResource = Vue.resource(`${endpoints.statusPath}?basic=true`); + this.pollResource = Vue.resource(`${endpoints.statusPath}?serializer=basic`); this.mergeActionsContentResource = Vue.resource(endpoints.mergeActionsContentPath); } diff --git a/app/assets/javascripts/vue_shared/components/loading_icon.vue b/app/assets/javascripts/vue_shared/components/loading_icon.vue index 494fe4468d9..15581d5c2a0 100644 --- a/app/assets/javascripts/vue_shared/components/loading_icon.vue +++ b/app/assets/javascripts/vue_shared/components/loading_icon.vue @@ -18,12 +18,6 @@ required: false, default: false, }, - - class: { - type: String, - required: false, - default: '', - }, }, computed: { @@ -31,7 +25,7 @@ return this.inline ? 'span' : 'div'; }, cssClass() { - return `fa-${this.size}x ${this.class}`.trim(); + return `fa-${this.size}x`; }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/popup_dialog.vue b/app/assets/javascripts/vue_shared/components/popup_dialog.vue index fc6421fecb9..9e8c10bdc1a 100644 --- a/app/assets/javascripts/vue_shared/components/popup_dialog.vue +++ b/app/assets/javascripts/vue_shared/components/popup_dialog.vue @@ -5,27 +5,17 @@ export default { props: { title: { type: String, - required: false, + required: true, }, text: { type: String, required: false, }, - hideFooter: { - type: Boolean, - required: false, - default: false, - }, kind: { type: String, required: false, default: 'primary', }, - modalDialogClass: { - type: String, - required: false, - default: '', - }, closeKind: { type: String, required: false, @@ -40,11 +30,6 @@ export default { type: String, required: true, }, - submitDisabled: { - type: Boolean, - required: false, - default: false, - }, }, computed: { @@ -72,57 +57,43 @@ export default { </script> <template> -<div class="modal-open"> - <div - class="modal popup-dialog" - role="dialog" - tabindex="-1" - > - <div - :class="modalDialogClass" - class="modal-dialog" - role="document" - > - <div class="modal-content"> - <div class="modal-header"> - <slot name="header"> - <h4 class="modal-title pull-left"> - {{this.title}} - </h4> - <button - type="button" - class="close pull-right" - @click="close" - aria-label="Close" - > - <span aria-hidden="true">×</span> - </button> - </slot> - </div> - <div class="modal-body"> - <slot name="body" :text="text"> - <p>{{this.text}}</p> - </slot> - </div> - <div class="modal-footer" v-if="!hideFooter"> - <button - type="button" - class="btn pull-left" - :class="btnCancelKindClass" - @click="close"> - {{ closeButtonLabel }} - </button> - <button - type="button" - class="btn pull-right" - :class="btnKindClass" - @click="emitSubmit(true)"> - {{ primaryButtonLabel }} - </button> - </div> +<div + class="modal popup-dialog" + role="dialog" + tabindex="-1"> + <div class="modal-dialog" role="document"> + <div class="modal-content"> + <div class="modal-header"> + <button type="button" + class="close" + @click="close" + aria-label="Close"> + <span aria-hidden="true">×</span> + </button> + <h4 class="modal-title">{{this.title}}</h4> + </div> + <div class="modal-body"> + <slot name="body" :text="text"> + <p>{{text}}</p> + </slot> + </div> + <div class="modal-footer"> + <button + type="button" + class="btn" + :class="btnCancelKindClass" + @click="close"> + {{ closeButtonLabel }} + </button> + <button + type="button" + class="btn" + :class="btnKindClass" + @click="emitSubmit(true)"> + {{ primaryButtonLabel }} + </button> </div> </div> </div> - <div class="modal-backdrop fade in" /> </div> </template> diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 1cfd7ef01a8..96f9dda26c4 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -4,9 +4,6 @@ .cred { color: $common-red; } .cgreen { color: $common-green; } .cdark { color: $common-gray-dark; } -.text-secondary { - color: $gl-text-color-secondary; -} /** COMMON CLASSES **/ .prepend-top-0 { margin-top: 0; } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 63697fd38a7..1aa53b8f8cf 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -37,7 +37,6 @@ .dropdown-menu-nav { @include set-visible; display: block; - min-height: 40px; @media (max-width: $screen-xs-max) { width: 100%; @@ -777,12 +776,15 @@ a, button, .menu-item { + margin-bottom: 0; border-radius: 0; box-shadow: none; padding: 8px 16px; text-align: left; white-space: normal; width: 100%; + font-weight: $gl-font-weight-normal; + line-height: normal; &.dropdown-menu-user-link { white-space: nowrap; diff --git a/app/assets/stylesheets/framework/gitlab-theme.scss b/app/assets/stylesheets/framework/gitlab-theme.scss index 52b87de7a3d..dc591c06c88 100644 --- a/app/assets/stylesheets/framework/gitlab-theme.scss +++ b/app/assets/stylesheets/framework/gitlab-theme.scss @@ -216,12 +216,9 @@ body { color: $theme-gray-900; } - &.active > a { + &.active > a, + &.active > a:hover { color: $white-light; - - &:hover { - color: $white-light; - } } } } diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index d79444fad79..62ba74ff582 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -239,10 +239,8 @@ fill: currentColor; } - &.header-user-dropdown-toggle { - .header-user-avatar { - border-color: $white-light; - } + &.header-user-dropdown-toggle .header-user-avatar { + border-color: $white-light; } } } diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index d218fb6d702..1cebd02df48 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -42,11 +42,3 @@ body.modal-open { width: 98%; } } - -.modal.popup-dialog { - display: block; -} - -.modal-body { - background-color: $modal-body-bg; -} diff --git a/app/assets/stylesheets/framework/tw_bootstrap_variables.scss b/app/assets/stylesheets/framework/tw_bootstrap_variables.scss index a23131e0818..3ea77eb7a43 100644 --- a/app/assets/stylesheets/framework/tw_bootstrap_variables.scss +++ b/app/assets/stylesheets/framework/tw_bootstrap_variables.scss @@ -164,36 +164,3 @@ $pre-border-color: $border-color; $table-bg-accent: $gray-light; $zindex-popover: 900; - -//== Modals -// -//## - -//** Padding applied to the modal body -$modal-inner-padding: $gl-padding; - -//** Padding applied to the modal title -$modal-title-padding: $gl-padding; -//** Modal title line-height -// $modal-title-line-height: $line-height-base - -//** Background color of modal content area -$modal-content-bg: $gray-light; -$modal-body-bg: $white-light; -//** Modal content border color -// $modal-content-border-color: rgba(0,0,0,.2) -//** Modal content border color **for IE8** -// $modal-content-fallback-border-color: #999 - -//** Modal backdrop background color -// $modal-backdrop-bg: #000 -//** Modal backdrop opacity -// $modal-backdrop-opacity: .5 -//** Modal header border color -// $modal-header-border-color: #e5e5e5 -//** Modal footer border color -// $modal-footer-border-color: $modal-header-border-color - -// $modal-lg: 900px -// $modal-md: 600px -// $modal-sm: 300px diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 48532503263..88600a0e6d3 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -542,7 +542,9 @@ } .participants-list { - margin: -5px; + display: flex; + flex-wrap: wrap; + margin: -7px; } @@ -553,7 +555,7 @@ .participants-author { display: inline-block; - padding: 5px; + padding: 7px; &:nth-of-type(7n) { padding-right: 0; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 3bd0e3ad535..312917bd13a 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -269,7 +269,7 @@ ul.notes { display: none; } - &.system-note-commit-list { + &.system-note-commit-list:not(.hide-shade) { max-height: 70px; overflow: hidden; display: block; @@ -291,16 +291,6 @@ ul.notes { bottom: 0; background: linear-gradient(rgba($white-light, 0.1) -100px, $white-light 100%); } - - &.hide-shade { - max-height: 100%; - overflow: auto; - - &::after { - display: none; - background: transparent; - } - } } } } diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index e8c7f8a8fc0..1bb4e3cc345 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -1,10 +1,14 @@ -.monaco-loader { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: $black-transparent; +.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, @@ -41,6 +45,7 @@ } .tree-content-holder { + display: -webkit-flex; display: flex; min-height: 300px; } @@ -50,7 +55,9 @@ } .panel-right { + display: -webkit-flex; display: flex; + -webkit-flex-direction: column; flex-direction: column; width: 80%; height: 100%; @@ -68,10 +75,6 @@ text-decoration: underline; } } - - .cursor { - display: none !important; - } } .blob-no-preview { @@ -81,21 +84,12 @@ } } - &.edit-mode { - .blob-viewer-container { - overflow: hidden; - } - - .monaco-editor.vs { - .cursor { - background: $black; - border-color: $black; - display: block !important; - } - } + &.blob-editor-container { + overflow: hidden; } .blob-viewer-container { + -webkit-flex: 1; flex: 1; overflow: auto; @@ -125,6 +119,7 @@ } #tabs { + position: relative; flex-shrink: 0; display: flex; width: 100%; @@ -153,6 +148,10 @@ vertical-align: middle; text-decoration: none; margin-right: 12px; + + &:focus { + outline: none; + } } .close-btn { @@ -299,23 +298,3 @@ width: 100%; } } - -@keyframes swipeRightAppear { - 0% { - transform: scaleX(0.00); - } - - 100% { - transform: scaleX(1.00); - } -} - -@keyframes swipeRightDissapear { - 0% { - transform: scaleX(1.00); - } - - 100% { - transform: scaleX(0.00); - } -} diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index fe1334c0cfe..6a5e4538717 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -74,7 +74,7 @@ class Projects::IssuesController < Projects::ApplicationController respond_to do |format| format.html format.json do - render json: serializer.represent(@issue) + render json: serializer.represent(@issue, serializer: params[:serializer]) end end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index c5204080333..2b0294c8387 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -83,7 +83,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo format.json do Gitlab::PollingInterval.set_header(response, interval: 10_000) - render json: serializer.represent(@merge_request, basic: params[:basic]) + render json: serializer.represent(@merge_request, serializer: params[:serializer]) end format.patch do diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index c94384d2a1a..980bbf699b6 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -2,13 +2,13 @@ class Projects::MilestonesController < Projects::ApplicationController include MilestoneActions before_action :check_issuables_available! - before_action :milestone, only: [:edit, :update, :destroy, :show, :merge_requests, :participants, :labels] + before_action :milestone, only: [:edit, :update, :destroy, :show, :merge_requests, :participants, :labels, :promote] # Allow read any milestone before_action :authorize_read_milestone! # Allow admin milestone - before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels] + before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels, :promote] respond_to :html @@ -69,6 +69,14 @@ class Projects::MilestonesController < Projects::ApplicationController end end + def promote + promoted_milestone = Milestones::PromoteService.new(project, current_user).execute(milestone) + flash[:notice] = "Milestone has been promoted to group milestone." + redirect_to group_milestone_path(project.group, promoted_milestone.iid) + rescue Milestones::PromoteService::PromoteMilestoneError => error + redirect_to milestone, alert: error.message + end + def destroy return access_denied! unless can?(current_user, :admin_milestone, @project) diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index c4a621160af..7112c6ee470 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -20,6 +20,17 @@ module BoardsHelper project_issues_path(@project) end + def current_board_json + board = @board || @boards.first + + board.to_json( + only: [:id, :name, :milestone_id], + include: { + milestone: { only: [:title] } + } + ) + end + def board_base_url project_boards_path(@project) end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index baa2d6e375e..d0069cd48cf 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -33,15 +33,17 @@ module IssuablesHelper end def serialize_issuable(issuable) - case issuable - when Issue - IssueSerializer.new(current_user: current_user, project: issuable.project).represent(issuable).to_json - when MergeRequest - MergeRequestSerializer - .new(current_user: current_user, project: issuable.project) - .represent(issuable) - .to_json - end + serializer_klass = case issuable + when Issue + IssueSerializer + when MergeRequest + MergeRequestSerializer + end + + serializer_klass + .new(current_user: current_user, project: issuable.project) + .represent(issuable) + .to_json end def template_dropdown_tag(issuable, &block) @@ -357,7 +359,8 @@ module IssuablesHelper def issuable_sidebar_options(issuable, can_edit_issuable) { - endpoint: "#{issuable_json_path(issuable)}?basic=true", + endpoint: "#{issuable_json_path(issuable)}?serializer=sidebar", + toggleSubscriptionEndpoint: toggle_subscription_path(issuable), 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, diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 5a74511afa7..8ada746b244 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -19,11 +19,7 @@ module NavHelper end elsif current_path?('jobs#show') %w[page-gutter build-sidebar right-sidebar-expanded] - elsif current_path?('wikis#show') || - current_path?('wikis#edit') || - current_path?('wikis#update') || - current_path?('wikis#history') || - current_path?('wikis#git_access') + elsif current_controller?('wikis') && current_action?('show', 'create', 'edit', 'update', 'history', 'git_access') %w[page-gutter wiki-sidebar right-sidebar-expanded] else [] diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb index 274b38a7708..f478c8ede18 100644 --- a/app/models/concerns/subscribable.rb +++ b/app/models/concerns/subscribable.rb @@ -13,6 +13,8 @@ module Subscribable end def subscribed?(user, project = nil) + return false unless user + if subscription = subscriptions.find_by(user: user, project: project) subscription.subscribed else diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb index 670b26d4ca3..b75387e236e 100644 --- a/app/models/merge_request_diff_commit.rb +++ b/app/models/merge_request_diff_commit.rb @@ -17,7 +17,9 @@ class MergeRequestDiffCommit < ActiveRecord::Base commit_hash.merge( merge_request_diff_id: merge_request_diff_id, relative_order: index, - sha: sha_attribute.type_cast_for_database(sha) + sha: sha_attribute.type_cast_for_database(sha), + authored_date: Gitlab::Database.sanitize_timestamp(commit_hash[:authored_date]), + committed_date: Gitlab::Database.sanitize_timestamp(commit_hash[:committed_date]) ) end diff --git a/app/models/project.rb b/app/models/project.rb index 7185b4d44fc..413866b994a 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -26,7 +26,15 @@ class Project < ActiveRecord::Base NUMBER_OF_PERMITTED_BOARDS = 1 UNKNOWN_IMPORT_URL = 'http://unknown.git'.freeze - LATEST_STORAGE_VERSION = 1 + # Hashed Storage versions handle rolling out new storage to project and dependents models: + # nil: legacy + # 1: repository + # 2: attachments + LATEST_STORAGE_VERSION = 2 + HASHED_STORAGE_FEATURES = { + repository: 1, + attachments: 2 + }.freeze cache_markdown_field :description, pipeline: :description @@ -120,6 +128,7 @@ class Project < ActiveRecord::Base has_one :mock_deployment_service has_one :mock_monitoring_service has_one :microsoft_teams_service + has_one :packagist_service # TODO: replace these relations with the fork network versions has_one :forked_project_link, foreign_key: "forked_to_project_id" @@ -1083,6 +1092,7 @@ class Project < ActiveRecord::Base def hook_attrs(backward: true) attrs = { + id: id, name: name, description: description, web_url: web_url, @@ -1394,6 +1404,19 @@ class Project < ActiveRecord::Base end end + def after_rename_repo + path_before_change = previous_changes['path'].first + + # We need to check if project had been rolled out to move resource to hashed storage or not and decide + # if we need execute any take action or no-op. + + unless hashed_storage?(:attachments) + Gitlab::UploadsTransfer.new.rename_project(path_before_change, self.path, namespace.full_path) + end + + Gitlab::PagesTransfer.new.rename_project(path_before_change, self.path, namespace.full_path) + end + def rename_repo_notify! send_move_instructions(full_path_was) expires_full_path_cache @@ -1404,13 +1427,6 @@ class Project < ActiveRecord::Base reload_repository! end - def after_rename_repo - path_before_change = previous_changes['path'].first - - Gitlab::UploadsTransfer.new.rename_project(path_before_change, self.path, namespace.full_path) - Gitlab::PagesTransfer.new.rename_project(path_before_change, self.path, namespace.full_path) - end - def running_or_pending_build_count(force: false) Rails.cache.fetch(['projects', id, 'running_or_pending_build_count'], force: force) do builds.running_or_pending.count(:all) @@ -1600,8 +1616,13 @@ class Project < ActiveRecord::Base [nil, 0].include?(self.storage_version) end - def hashed_storage? - self.storage_version && self.storage_version >= 1 + # Check if Hashed Storage is enabled for the project with at least informed feature rolled out + # + # @param [Symbol] feature that needs to be rolled out for the project (:repository, :attachments) + def hashed_storage?(feature) + raise ArgumentError, "Invalid feature" unless HASHED_STORAGE_FEATURES.include?(feature) + + self.storage_version && self.storage_version >= HASHED_STORAGE_FEATURES[feature] end def renamed? @@ -1637,7 +1658,7 @@ class Project < ActiveRecord::Base end def migrate_to_hashed_storage! - return if hashed_storage? + return if hashed_storage?(:repository) update!(repository_read_only: true) @@ -1662,7 +1683,7 @@ class Project < ActiveRecord::Base def storage @storage ||= - if hashed_storage? + if hashed_storage?(:repository) Storage::HashedProject.new(self) else Storage::LegacyProject.new(self) diff --git a/app/models/project_services/packagist_service.rb b/app/models/project_services/packagist_service.rb new file mode 100644 index 00000000000..f68a0c1a3c3 --- /dev/null +++ b/app/models/project_services/packagist_service.rb @@ -0,0 +1,65 @@ +class PackagistService < Service + include HTTParty + + prop_accessor :username, :token, :server + + validates :username, presence: true, if: :activated? + validates :token, presence: true, if: :activated? + + default_value_for :push_events, true + default_value_for :tag_push_events, true + + after_save :compose_service_hook, if: :activated? + + def title + 'Packagist' + end + + def description + 'Update your project on Packagist, the main Composer repository' + end + + def self.to_param + 'packagist' + end + + def fields + [ + { type: 'text', name: 'username', placeholder: '', required: true }, + { type: 'text', name: 'token', placeholder: '', required: true }, + { type: 'text', name: 'server', placeholder: 'https://packagist.org', required: false } + ] + end + + def self.supported_events + %w(push merge_request tag_push) + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + service_hook.execute(data) + end + + def test(data) + begin + result = execute(data) + return { success: false, result: result[:message] } if result[:http_status] != 202 + rescue StandardError => error + return { success: false, result: error } + end + + { success: true, result: result[:message] } + end + + def compose_service_hook + hook = service_hook || build_service_hook + hook.url = hook_url + hook.save + end + + def hook_url + base_url = server.present? ? server : 'https://packagist.org' + "#{base_url}/api/update-package?username=#{username}&apiToken=#{token}" + end +end diff --git a/app/models/service.rb b/app/models/service.rb index 6b64079215f..fdd2605e3e3 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -238,6 +238,7 @@ class Service < ActiveRecord::Base kubernetes mattermost_slash_commands mattermost + packagist pipelines_email pivotaltracker prometheus diff --git a/app/serializers/issuable_sidebar_entity.rb b/app/serializers/issuable_sidebar_entity.rb new file mode 100644 index 00000000000..ff23d8bf0c7 --- /dev/null +++ b/app/serializers/issuable_sidebar_entity.rb @@ -0,0 +1,16 @@ +class IssuableSidebarEntity < Grape::Entity + include RequestAwareEntity + + expose :participants, using: ::API::Entities::UserBasic do |issuable| + issuable.participants(request.current_user) + end + + 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_serializer.rb b/app/serializers/issue_serializer.rb index 4fff54a9126..2555595379b 100644 --- a/app/serializers/issue_serializer.rb +++ b/app/serializers/issue_serializer.rb @@ -1,3 +1,16 @@ class IssueSerializer < BaseSerializer - entity IssueEntity + # This overrided method takes care of which entity should be used + # to serialize the `issue` based on `basic` 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 'sidebar' + IssueSidebarEntity + else + IssueEntity + end + + super(merge_request, opts, entity) + end end diff --git a/app/serializers/issue_sidebar_entity.rb b/app/serializers/issue_sidebar_entity.rb new file mode 100644 index 00000000000..6c823dbfe95 --- /dev/null +++ b/app/serializers/issue_sidebar_entity.rb @@ -0,0 +1,3 @@ +class IssueSidebarEntity < IssuableSidebarEntity + expose :assignees, using: API::Entities::UserBasic +end diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb index 8461f158bb5..d54a6516aed 100644 --- a/app/serializers/merge_request_basic_entity.rb +++ b/app/serializers/merge_request_basic_entity.rb @@ -1,11 +1,7 @@ -class MergeRequestBasicEntity < Grape::Entity +class MergeRequestBasicEntity < IssuableSidebarEntity expose :assignee_id expose :merge_status expose :merge_error expose :state expose :source_branch_exists?, as: :source_branch_exists - expose :time_estimate - expose :total_time_spent - expose :human_time_estimate - expose :human_total_time_spent end diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb index f67034ce47a..e9d98d8baca 100644 --- a/app/serializers/merge_request_serializer.rb +++ b/app/serializers/merge_request_serializer.rb @@ -3,7 +3,14 @@ class MergeRequestSerializer < BaseSerializer # to serialize the `merge_request` based on `basic` key in `opts` param. # Hence, `entity` doesn't need to be declared on the class scope. def represent(merge_request, opts = {}) - entity = opts[:basic] ? MergeRequestBasicEntity : MergeRequestEntity + entity = + case opts[:serializer] + when 'basic', 'sidebar' + MergeRequestBasicEntity + else + MergeRequestEntity + end + super(merge_request, opts, entity) end end diff --git a/app/services/milestones/promote_service.rb b/app/services/milestones/promote_service.rb new file mode 100644 index 00000000000..bd9cfd4e0ea --- /dev/null +++ b/app/services/milestones/promote_service.rb @@ -0,0 +1,80 @@ +module Milestones + class PromoteService < Milestones::BaseService + PromoteMilestoneError = Class.new(StandardError) + + def execute(milestone) + check_project_milestone!(milestone) + + Milestone.transaction do + # Destroy all milestones with same title across projects + destroy_old_milestones(milestone) + + group_milestone = clone_project_milestone(milestone) + + move_children_to_group_milestone(group_milestone) + + # Just to be safe + unless group_milestone.valid? + raise_error(group_milestone.errors.full_messages.to_sentence) + end + + group_milestone + end + end + + private + + def milestone_ids_for_merge(group_milestone) + # Pluck need to be used here instead of select so the array of ids + # is persistent after old milestones gets deleted. + @milestone_ids_for_merge ||= begin + search_params = { title: group_milestone.title, project_ids: group_project_ids, state: 'all' } + milestones = MilestonesFinder.new(search_params).execute + milestones.pluck(:id) + end + end + + def move_children_to_group_milestone(group_milestone) + milestone_ids_for_merge(group_milestone).in_groups_of(100) do |milestone_ids| + update_children(group_milestone, milestone_ids) + end + end + + def check_project_milestone!(milestone) + raise_error('Only project milestones can be promoted.') unless milestone.project_milestone? + end + + def clone_project_milestone(milestone) + params = milestone.slice(:title, :description, :start_date, :due_date, :state_event) + + create_service = CreateService.new(group, current_user, params) + + create_service.execute + end + + def update_children(group_milestone, milestone_ids) + issues = Issue.where(project_id: group_project_ids, milestone_id: milestone_ids) + merge_requests = MergeRequest.where(source_project_id: group_project_ids, milestone_id: milestone_ids) + + [issues, merge_requests].each do |issuable_collection| + issuable_collection.update_all(milestone_id: group_milestone.id) + end + end + + def group + @group ||= parent.group || raise_error('Project does not belong to a group.') + end + + def destroy_old_milestones(group_milestone) + Milestone.where(id: milestone_ids_for_merge(group_milestone)).destroy_all + end + + def group_project_ids + @group_project_ids ||= group.projects.map(&:id) + end + + def raise_error(message) + raise PromoteMilestoneError, "Promotion failed - #{message}" + end + end +end diff --git a/app/services/projects/hashed_storage_migration_service.rb b/app/services/projects/hashed_storage_migration_service.rb index 41259de3a16..f5945f3b87f 100644 --- a/app/services/projects/hashed_storage_migration_service.rb +++ b/app/services/projects/hashed_storage_migration_service.rb @@ -10,7 +10,7 @@ module Projects end def execute - return if project.hashed_storage? + return if project.hashed_storage?(:repository) @old_disk_path = project.disk_path has_wiki = project.wiki.repository_exists? diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb index 7027ac4b5db..d4ba3a028be 100644 --- a/app/uploaders/file_uploader.rb +++ b/app/uploaders/file_uploader.rb @@ -30,7 +30,7 @@ class FileUploader < GitlabUploader # # Returns a String without a trailing slash def self.dynamic_path_segment(model) - File.join(CarrierWave.root, base_dir, model.full_path) + File.join(CarrierWave.root, base_dir, model.disk_path) end attr_accessor :model diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index a5153df1159..9fc297ab7f6 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -23,14 +23,18 @@ = milestone_date_range(@milestone) .milestone-buttons - if can?(current_user, :admin_milestone, @project) + = link_to edit_project_milestone_path(@project, @milestone), class: "btn btn-grouped btn-nr" do + Edit + + - if @project.group + = link_to promote_project_milestone_path(@milestone.project, @milestone), title: "Promote to Group Milestone", class: 'btn btn-grouped', data: { confirm: "Promoting this milestone will make it available for all projects inside the group. Existing project milestones with the same name will be merged. Are you sure?", toggle: "tooltip" }, method: :post do + Promote + - if @milestone.active? = link_to 'Close milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :close }), method: :put, class: "btn btn-close btn-nr btn-grouped" - else = link_to 'Reopen milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-reopen btn-nr btn-grouped" - = link_to edit_project_milestone_path(@project, @milestone), class: "btn btn-grouped btn-nr" do - Edit - = link_to project_milestone_path(@project, @milestone), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-grouped btn-danger" do Delete @@ -40,6 +44,7 @@ .detail-page-description.milestone-detail %h2.title = markdown_field(@milestone, :title) + %div - if @milestone.description.present? .description diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 7ea19e6c828..c02f7ee37ed 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -2,14 +2,14 @@ .tree-ref-holder = render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true - - if show_new_repo? + - if show_new_repo? && can_push_branch?(@project, @ref) .js-new-dropdown - else = render 'projects/tree/old_tree_header' .tree-controls - if show_new_repo? - = render 'shared/repo/editable_mode' + .editable-mode - else = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' diff --git a/app/views/shared/issuable/_participants.html.haml b/app/views/shared/issuable/_participants.html.haml deleted file mode 100644 index 3f553c9fede..00000000000 --- a/app/views/shared/issuable/_participants.html.haml +++ /dev/null @@ -1,18 +0,0 @@ -- participants_row = 7 -- participants_size = participants.size -- participants_extra = participants_size - participants_row -.block.participants - .sidebar-collapsed-icon - = icon('users') - %span - = participants.count - .title.hide-collapsed - = pluralize participants.count, "participant" - .hide-collapsed.participants-list - - participants.each do |participant| - .participants-author.js-participants-author - = link_to_member(@project, participant, name: false, size: 24, lazy_load: true) - - if participants_extra > 0 - .hide-collapsed.participants-more - %button.btn-transparent.btn-blank.js-participants-more{ type: 'button', data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } } - + #{participants_extra} more diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 7b7411b1e23..e0009a35b9f 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -123,17 +123,10 @@ %script#js-lock-issue-data{ type: "application/json" }= { is_locked: issuable.discussion_locked?, is_editable: can_edit_issuable }.to_json.html_safe #js-lock-entry-point - = render "shared/issuable/participants", participants: issuable.participants(current_user) + .js-sidebar-participants-entry-point + - if current_user - - subscribed = issuable.subscribed?(current_user, @project) - .block.light.subscription{ data: { url: toggle_subscription_path(issuable) } } - .sidebar-collapsed-icon - = icon('rss', 'aria-hidden': 'true') - %span.issuable-header-text.hide-collapsed.pull-left - Notifications - - subscribtion_status = subscribed ? 'subscribed' : 'unsubscribed' - %button.btn.btn-default.pull-right.js-subscribe-button.issuable-subscribe-button.hide-collapsed{ type: "button" } - %span= subscribed ? 'Unsubscribe' : 'Subscribe' + .js-sidebar-subscriptions-entry-point - project_ref = cross_project_reference(@project, issuable) .block.project-reference diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index 305e2542281..7ba8f9d4313 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -49,6 +49,13 @@ = link_to edit_project_milestone_path(milestone.project, milestone), class: "btn btn-xs btn-grouped" do Edit \ + + - if @project.group + = link_to promote_project_milestone_path(milestone.project, milestone), title: "Promote to Group Milestone", class: 'btn btn-xs btn-grouped', data: { confirm: "Promoting this milestone will make it available for all projects inside the group. Existing project milestones with the same name will be merged. Are you sure?", toggle: "tooltip" }, method: :post do + Promote + = link_to 'Close Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-xs btn-close btn-grouped" + = link_to project_milestone_path(milestone.project, milestone), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-xs btn-remove btn-grouped" do Delete + diff --git a/app/views/shared/repo/_editable_mode.html.haml b/app/views/shared/repo/_editable_mode.html.haml deleted file mode 100644 index 73fdb8b523f..00000000000 --- a/app/views/shared/repo/_editable_mode.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -.editable-mode - %repo-edit-button diff --git a/app/views/shared/repo/_repo.html.haml b/app/views/shared/repo/_repo.html.haml index 7861f92b33f..5867ea58378 100644 --- a/app/views/shared/repo/_repo.html.haml +++ b/app/views/shared/repo/_repo.html.haml @@ -1,11 +1,12 @@ #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, - refs_url: refs_project_path(project, format: :json), project_url: project_path(project), project_id: project.id, - blob_url: namespace_project_blob_path(project.namespace, project, '{{branch}}'), - new_mr_template_url: namespace_project_new_merge_request_path(project.namespace, project, merge_request: { source_branch: '{{source_branch}}' }), + 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 } } |