diff options
author | Alfredo Sumaran <alfredo@gitlab.com> | 2017-06-07 22:45:57 +0300 |
---|---|---|
committer | Alfredo Sumaran <alfredo@gitlab.com> | 2017-06-07 22:45:57 +0300 |
commit | 3ec37e2622f6729300a988c8f58dfb6c2aecb996 (patch) | |
tree | d060b5acf30093cbe1d3642ea6dd11e79ccbf6c5 /app/assets | |
parent | a65f07a256b95ce1c38342518f9469cbf3abf609 (diff) | |
parent | fc1090d9f39231e31f929e37b9703db9738b457c (diff) |
Merge branch 'master' into 25426-group-dashboard-ui
Diffstat (limited to 'app/assets')
92 files changed, 2227 insertions, 565 deletions
diff --git a/app/assets/images/i2p-step.svg b/app/assets/images/i2p-step.svg new file mode 100644 index 00000000000..8886092ed82 --- /dev/null +++ b/app/assets/images/i2p-step.svg @@ -0,0 +1,4 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 120" enable-background="new 0 0 12 120"> + <path d="m12 6c0-3.309-2.691-6-6-6s-6 2.691-6 6c0 2.967 2.167 5.431 5 5.91v108.09h2v-108.09c2.833-.479 5-2.943 5-5.91m-6 4c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4"/> +</svg> diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js index d816df831eb..5d060165f4b 100644 --- a/app/assets/javascripts/activities.js +++ b/app/assets/javascripts/activities.js @@ -5,7 +5,8 @@ import Cookies from 'js-cookie'; class Activities { constructor() { - Pager.init(20, true, false, this.updateTooltips); + Pager.init(20, true, false, data => data, this.updateTooltips); + $('.event-filter-link').on('click', (e) => { e.preventDefault(); this.toggleFilter(e.currentTarget); @@ -19,7 +20,7 @@ class Activities { reloadActivities() { $('.content_list').html(''); - Pager.init(20, true, false, this.updateTooltips); + Pager.init(20, true, false, data => data, this.updateTooltips); } toggleFilter(sender) { diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js index 23d91fdb259..36ce4fddb72 100644 --- a/app/assets/javascripts/behaviors/gl_emoji.js +++ b/app/assets/javascripts/behaviors/gl_emoji.js @@ -88,6 +88,7 @@ function installGlEmojiElement() { const hasCssSpriteFalback = fallbackSpriteClass && fallbackSpriteClass.length > 0; if ( + emojiUnicode && isEmojiUnicode && !isEmojiUnicodeSupported(generatedUnicodeSupportMap, emojiUnicode, unicodeVersion) ) { diff --git a/app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js b/app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js index 20ab2d7e827..4f8884d05ac 100644 --- a/app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js +++ b/app/assets/javascripts/behaviors/gl_emoji/is_emoji_unicode_supported.js @@ -28,7 +28,8 @@ function isSkinToneComboEmoji(emojiUnicode) { // doesn't support the skin tone versions of horse racing const horseRacingCodePoint = 127943;// parseInt('1F3C7', 16) function isHorceRacingSkinToneComboEmoji(emojiUnicode) { - return Array.from(emojiUnicode)[0].codePointAt(0) === horseRacingCodePoint && + const firstCharacter = Array.from(emojiUnicode)[0]; + return firstCharacter && firstCharacter.codePointAt(0) === horseRacingCodePoint && isSkinToneComboEmoji(emojiUnicode); } diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js index 0e4aa39226b..b94009ee76b 100644 --- a/app/assets/javascripts/boards/boards_bundle.js +++ b/app/assets/javascripts/boards/boards_bundle.js @@ -88,6 +88,8 @@ $(() => { if (list.type === 'closed') { list.position = Infinity; list.label = { description: 'Shows all closed issues. Moving an issue to this list closes it' }; + } else if (list.type === 'backlog') { + list.position = -1; } }); @@ -128,7 +130,7 @@ $(() => { }, computed: { disabled() { - return !this.store.lists.filter(list => list.type !== 'blank' && list.type !== 'done').length; + return !this.store.lists.filter(list => !list.preset).length; }, tooltipTitle() { if (this.disabled) { diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index 9ba84489910..adb7360327c 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -1,6 +1,7 @@ /* eslint-disable comma-dangle, space-before-function-paren, one-var */ /* global Sortable */ import Vue from 'vue'; +import AccessorUtilities from '../../lib/utils/accessor'; import boardList from './board_list'; import boardBlankState from './board_blank_state'; import './board_delete'; @@ -22,6 +23,10 @@ gl.issueBoards.Board = Vue.extend({ disabled: Boolean, issueLinkBase: String, rootPath: String, + boardId: { + type: String, + required: true, + }, }, data () { return { @@ -78,7 +83,16 @@ gl.issueBoards.Board = Vue.extend({ methods: { showNewIssueForm() { this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm; - } + }, + toggleExpanded(e) { + if (this.list.isExpandable && !e.target.classList.contains('js-no-trigger-collapse')) { + this.list.isExpanded = !this.list.isExpanded; + + if (AccessorUtilities.isLocalStorageAccessSafe()) { + localStorage.setItem(`boards.${this.boardId}.${this.list.type}.expanded`, this.list.isExpanded); + } + } + }, }, mounted () { this.sortableOptions = gl.issueBoards.getBoardSortableDefaultOptions({ @@ -102,4 +116,11 @@ gl.issueBoards.Board = Vue.extend({ this.sortable = Sortable.create(this.$el.parentNode, this.sortableOptions); }, + created() { + if (this.list.isExpandable && AccessorUtilities.isLocalStorageAccessSafe()) { + const isCollapsed = localStorage.getItem(`boards.${this.boardId}.${this.list.type}.expanded`) === 'false'; + + this.list.isExpanded = !isCollapsed; + } + }, }); diff --git a/app/assets/javascripts/boards/components/board_list.js b/app/assets/javascripts/boards/components/board_list.js index 7ee2696e720..bebca17fb1e 100644 --- a/app/assets/javascripts/boards/components/board_list.js +++ b/app/assets/javascripts/boards/components/board_list.js @@ -57,6 +57,9 @@ export default { scrollTop() { return this.$refs.list.scrollTop + this.listHeight(); }, + scrollToTop() { + this.$refs.list.scrollTop = 0; + }, loadNextPage() { const getIssues = this.list.nextPage(); const loadingDone = () => { @@ -108,6 +111,7 @@ export default { }, created() { eventHub.$on(`hide-issue-form-${this.list.id}`, this.toggleForm); + eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop); }, mounted() { const options = gl.issueBoards.getBoardSortableDefaultOptions({ @@ -150,6 +154,7 @@ export default { }, beforeDestroy() { eventHub.$off(`hide-issue-form-${this.list.id}`, this.toggleForm); + eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop); this.$refs.list.removeEventListener('scroll', this.onScroll); }, template: ` @@ -160,9 +165,11 @@ export default { v-if="loading"> <loading-icon /> </div> - <board-new-issue - :list="list" - v-if="list.type !== 'closed' && showIssueForm"/> + <transition name="slide-down"> + <board-new-issue + :list="list" + v-if="list.type !== 'closed' && showIssueForm"/> + </transition> <ul class="board-list" v-show="!loading" diff --git a/app/assets/javascripts/boards/components/board_new_issue.js b/app/assets/javascripts/boards/components/board_new_issue.js index 1ce95b62138..b1c47b09c35 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.js +++ b/app/assets/javascripts/boards/components/board_new_issue.js @@ -48,6 +48,7 @@ export default { this.error = true; }); + eventHub.$emit(`scroll-board-list-${this.list.id}`); this.cancel(); }, cancel() { @@ -75,6 +76,7 @@ export default { type="text" v-model="title" ref="input" + autocomplete="off" :id="list.id + '-title'" /> <div class="clearfix prepend-top-10"> <button class="btn btn-success pull-left" diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js index 4699ef5a51c..daef01bc93d 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.js +++ b/app/assets/javascripts/boards/components/issue_card_inner.js @@ -152,6 +152,7 @@ gl.issueBoards.IssueCardInner = Vue.extend({ <div class="card-assignee"> <user-avatar-link v-for="(assignee, index) in issue.assignees" + :key="assignee.id" v-if="shouldRenderAssignee(index)" class="js-no-trigger" :link-href="assigneeUrl(assignee)" diff --git a/app/assets/javascripts/boards/components/modal/footer.js b/app/assets/javascripts/boards/components/modal/footer.js index fe7ab2db85d..478a1335b2b 100644 --- a/app/assets/javascripts/boards/components/modal/footer.js +++ b/app/assets/javascripts/boards/components/modal/footer.js @@ -26,7 +26,8 @@ gl.issueBoards.ModalFooter = Vue.extend({ }, methods: { addIssues() { - const list = this.modal.selectedList || this.state.lists[0]; + const firstListIndex = 1; + const list = this.modal.selectedList || this.state.lists[firstListIndex]; const selectedIssues = ModalStore.getSelectedIssues(); const issueIds = selectedIssues.map(issue => issue.globalId); diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.js b/app/assets/javascripts/boards/components/modal/lists_dropdown.js index 8cd15df90fa..4684ea76647 100644 --- a/app/assets/javascripts/boards/components/modal/lists_dropdown.js +++ b/app/assets/javascripts/boards/components/modal/lists_dropdown.js @@ -11,7 +11,7 @@ gl.issueBoards.ModalFooterListsDropdown = Vue.extend({ }, computed: { selected() { - return this.modal.selectedList || this.state.lists[0]; + return this.modal.selectedList || this.state.lists[1]; }, }, destroyed() { diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 90561d0f7a8..548de1a4c52 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -12,7 +12,9 @@ class List { this.position = obj.position; this.title = obj.title; this.type = obj.list_type; - this.preset = ['closed', 'blank'].indexOf(this.type) > -1; + this.preset = ['backlog', 'closed', 'blank'].indexOf(this.type) > -1; + this.isExpandable = ['backlog', 'closed'].indexOf(this.type) > -1; + this.isExpanded = true; this.page = 1; this.loading = true; this.loadingMore = false; @@ -103,13 +105,19 @@ class List { } newIssue (issue) { - this.addIssue(issue); + this.addIssue(issue, null, 0); this.issuesSize += 1; return gl.boardService.newIssue(this.id, issue) .then((resp) => { const data = resp.json(); issue.id = data.iid; + }) + .then(() => { + if (this.issuesSize > 1) { + const moveBeforeIid = this.issues[1].id; + gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeIid); + } }); } diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index ad9997ac334..1e12d4ca415 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -22,6 +22,7 @@ gl.issueBoards.BoardsStore = { create () { this.state.lists = []; this.filter.path = gl.utils.getUrlParamsArray().join('&'); + this.detail = { issue: {} }; }, addList (listObj, defaultAvatar) { const list = new List(listObj, defaultAvatar); @@ -31,10 +32,14 @@ gl.issueBoards.BoardsStore = { }, new (listObj) { const list = this.addList(listObj); + const backlogList = this.findList('type', 'backlog', 'backlog'); list .save() .then(() => { + // Remove any new issues from the backlog + // as they will be visible in the new list + list.issues.forEach(backlogList.removeIssue.bind(backlogList)); this.state.lists = _.sortBy(this.state.lists, 'position'); }) .catch(() => { @@ -47,7 +52,7 @@ gl.issueBoards.BoardsStore = { }, shouldAddBlankState () { // Decide whether to add the blank state - return !(this.state.lists.filter(list => list.type !== 'closed')[0]); + return !(this.state.lists.filter(list => list.type !== 'backlog' && list.type !== 'closed')[0]); }, addBlankState () { if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return; @@ -100,7 +105,7 @@ gl.issueBoards.BoardsStore = { issueTo.removeLabel(listFrom.label); } - if (listTo.type === 'closed') { + if (listTo.type === 'closed' && listFrom.type !== 'backlog') { issueLists.forEach((list) => { list.removeIssue(issue); }); diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js index 1a602cbd8a7..072a899e9f2 100644 --- a/app/assets/javascripts/build.js +++ b/app/assets/javascripts/build.js @@ -64,7 +64,7 @@ window.Build = (function () { $(window) .off('resize.build') - .on('resize.build', this.sidebarOnResize.bind(this)); + .on('resize.build', _.throttle(this.sidebarOnResize.bind(this), 100)); this.updateArtifactRemoveDate(); @@ -250,6 +250,7 @@ window.Build = (function () { Build.prototype.sidebarOnResize = function () { this.toggleSidebar(this.shouldHideSidebarForViewport()); + this.verifyTopPosition(); if (this.$scrollContainer.getNiceScroll(0)) { diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.js b/app/assets/javascripts/commit/pipelines/pipelines_table.js index 98698143d22..082fbafb740 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.js @@ -118,7 +118,7 @@ export default Vue.component('pipelines-table', { eventHub.$on('refreshPipelines', this.fetchPipelines); }, - beforeDestroyed() { + beforeDestroy() { eventHub.$off('refreshPipelines'); }, diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js index e3f9eaaf39c..2b0bf49cf92 100644 --- a/app/assets/javascripts/commits.js +++ b/app/assets/javascripts/commits.js @@ -7,6 +7,8 @@ window.CommitsList = (function() { CommitsList.timer = null; CommitsList.init = function(limit) { + this.$contentList = $('.content_list'); + $("body").on("click", ".day-commits-table li.commit", function(e) { if (e.target.nodeName !== "A") { location.href = $(this).attr("url"); @@ -14,9 +16,9 @@ window.CommitsList = (function() { return false; } }); - Pager.init(limit, false, false, function() { - gl.utils.localTimeAgo($('.js-timeago')); - }); + + Pager.init(limit, false, false, this.processCommits); + this.content = $("#commits-list"); this.searchField = $("#commits-search"); this.lastSearch = this.searchField.val(); @@ -62,5 +64,34 @@ window.CommitsList = (function() { }); }; + // Prepare loaded data. + CommitsList.processCommits = (data) => { + let processedData = data; + const $processedData = $(processedData); + const $commitsHeadersLast = CommitsList.$contentList.find('li.js-commit-header').last(); + const lastShownDay = $commitsHeadersLast.data('day'); + const $loadedCommitsHeadersFirst = $processedData.filter('li.js-commit-header').first(); + const loadedShownDayFirst = $loadedCommitsHeadersFirst.data('day'); + let commitsCount; + + // If commits headers show the same date, + // remove the last header and change the previous one. + if (lastShownDay === loadedShownDayFirst) { + // Last shown commits count under the last commits header. + commitsCount = $commitsHeadersLast.nextUntil('li.js-commit-header').find('li.commit').length; + + // Remove duplicate of commits header. + processedData = $processedData.not(`li.js-commit-header[data-day="${loadedShownDayFirst}"]`); + + // Update commits count in the previous commits header. + commitsCount += Number($(processedData).nextUntil('li.js-commit-header').first().find('li.commit').length); + $commitsHeadersLast.find('span.commits-count').text(`${commitsCount} ${gl.text.pluralize('commit', commitsCount)}`); + } + + gl.utils.localTimeAgo($processedData.find('.js-timeago')); + + return processedData; + }; + return CommitsList; })(); diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index 5f6eed0c67c..a663e30dfd0 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -75,26 +75,32 @@ </script> <template> - <div class="col-lg-9 col-lg-offset-3 append-bottom-default deploy-keys"> + <div class="append-bottom-default deploy-keys"> <loading-icon v-if="isLoading && !hasKeys" size="2" label="Loading deploy keys" - /> + /> <div v-else-if="hasKeys"> <keys-panel title="Enabled deploy keys for this project" :keys="keys.enabled_keys" - :store="store" /> + :store="store" + :endpoint="endpoint" + /> <keys-panel title="Deploy keys from projects you have access to" :keys="keys.available_project_keys" - :store="store" /> + :store="store" + :endpoint="endpoint" + /> <keys-panel v-if="keys.public_keys.length" title="Public deploy keys available to any project" :keys="keys.public_keys" - :store="store" /> + :store="store" + :endpoint="endpoint" + /> </div> </div> </template> diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue index 0a06a481b96..904f7f64fa8 100644 --- a/app/assets/javascripts/deploy_keys/components/key.vue +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -11,6 +11,10 @@ type: Object, required: true, }, + endpoint: { + type: String, + required: true, + }, }, components: { actionBtn, @@ -19,6 +23,9 @@ timeagoDate() { return gl.utils.getTimeago().format(this.deployKey.created_at); }, + editDeployKeyPath() { + return `${this.endpoint}/${this.deployKey.id}/edit`; + }, }, methods: { isEnabled(id) { @@ -33,7 +40,8 @@ <div class="pull-left append-right-10 hidden-xs"> <i aria-hidden="true" - class="fa fa-key key-icon"> + class="fa fa-key key-icon" + > </i> </div> <div class="deploy-key-content key-list-item-info"> @@ -45,7 +53,8 @@ </div> <div v-if="deployKey.can_push" - class="write-access-allowed"> + class="write-access-allowed" + > Write access allowed </div> </div> @@ -53,7 +62,8 @@ <a v-for="project in deployKey.projects" class="label deploy-project-label" - :href="project.full_path"> + :href="project.full_path" + > {{ project.full_name }} </a> </div> @@ -61,20 +71,30 @@ <span class="key-created-at"> created {{ timeagoDate }} </span> + <a + v-if="deployKey.can_edit" + class="btn btn-small" + :href="editDeployKeyPath" + > + Edit + </a> <action-btn v-if="!isEnabled(deployKey.id)" :deploy-key="deployKey" - type="enable"/> + type="enable" + /> <action-btn v-else-if="deployKey.destroyed_when_orphaned && deployKey.almost_orphaned" :deploy-key="deployKey" btn-css-class="btn-warning" - type="remove" /> + type="remove" + /> <action-btn v-else :deploy-key="deployKey" btn-css-class="btn-warning" - type="disable" /> + type="disable" + /> </div> </div> </template> diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue index eccc470578b..9e6fb244af6 100644 --- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue +++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue @@ -20,6 +20,10 @@ type: Object, required: true, }, + endpoint: { + type: String, + required: true, + }, }, components: { key, @@ -34,18 +38,22 @@ ({{ keys.length }}) </h5> <ul class="well-list" - v-if="keys.length"> + v-if="keys.length" + > <li v-for="deployKey in keys" :key="deployKey.id"> <key :deploy-key="deployKey" - :store="store" /> + :store="store" + :endpoint="endpoint" + /> </li> </ul> <div class="settings-message text-center" - v-else-if="showHelpBox"> + v-else-if="showHelpBox" + > No deploy keys found. Create one with the form above. </div> </div> diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 3d7b8d924ac..a96aa703b3c 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -3,7 +3,7 @@ /* global ActiveTabMemoizer */ /* global ShortcutsNavigation */ /* global Build */ -/* global Issuable */ +/* global IssuableIndex */ /* global ShortcutsIssuable */ /* global ZenMode */ /* global Milestone */ @@ -55,6 +55,7 @@ import UsersSelect from './users_select'; import RefSelectDropdown from './ref_select_dropdown'; import GfmAutoComplete from './gfm_auto_complete'; import ShortcutsBlob from './shortcuts_blob'; +import initSettingsPanels from './settings_panels'; (function() { var Dispatcher; @@ -127,10 +128,9 @@ import ShortcutsBlob from './shortcuts_blob'; const filteredSearchManager = new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests'); filteredSearchManager.setup(); } - Issuable.init(); - new gl.IssuableBulkActions({ - prefixId: page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_', - }); + const pagePrefix = page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_'; + IssuableIndex.init(pagePrefix); + shortcut_handler = new ShortcutsNavigation(); new UsersSelect(); break; @@ -215,6 +215,16 @@ import ShortcutsBlob from './shortcuts_blob'; new gl.GLForm($('.tag-form')); new RefSelectDropdown($('.js-branch-select'), window.gl.availableRefs); break; + case 'projects:snippets:new': + case 'projects:snippets:edit': + case 'projects:snippets:create': + case 'projects:snippets:update': + case 'snippets:new': + case 'snippets:edit': + case 'snippets:create': + case 'snippets:update': + new gl.GLForm($('.snippet-form')); + break; case 'projects:releases:edit': new ZenMode(); new gl.GLForm($('.release-form')); @@ -379,6 +389,8 @@ import ShortcutsBlob from './shortcuts_blob'; // Initialize Protected Tag Settings new ProtectedTagCreate(); new ProtectedTagEditList(); + // Initialize expandable settings panels + initSettingsPanels(); break; case 'projects:ci_cd:show': new gl.ProjectVariables(); @@ -390,6 +402,9 @@ import ShortcutsBlob from './shortcuts_blob'; case 'users:show': new UserCallout(); break; + case 'admin:conversational_development_index:show': + new UserCallout(); + break; case 'snippets:show': new LineHighlighter(); new BlobViewer(); diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 111449bb8f7..98ddcc20036 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -5,7 +5,7 @@ import './preview_markdown'; window.DropzoneInput = (function() { function DropzoneInput(form) { - var updateAttachingMessage, $attachingFileMessage, $mdArea, $attachButton, $cancelButton, $retryLink, $uploadingErrorContainer, $uploadingErrorMessage, $uploadProgress, $uploadingProgressContainer, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divHover, divSpinner, dropzone, $formDropzone, formTextarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, maxFileSize, pasteText, uploadsPath, showError, showSpinner, uploadFile; + var updateAttachingMessage, $attachingFileMessage, $mdArea, $attachButton, $cancelButton, $retryLink, $uploadingErrorContainer, $uploadingErrorMessage, $uploadProgress, $uploadingProgressContainer, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divHover, divSpinner, dropzone, $formDropzone, formTextarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, maxFileSize, pasteText, uploadsPath, showError, showSpinner, uploadFile, addFileToForm; Dropzone.autoDiscover = false; divHover = '<div class="div-dropzone-hover"></div>'; iconPaperclip = '<i class="fa fa-paperclip div-dropzone-icon"></i>'; @@ -71,6 +71,7 @@ window.DropzoneInput = (function() { pasteText(response.link.markdown, shouldPad); // Show 'Attach a file' link only when all files have been uploaded. if (!processingFileCount) $attachButton.removeClass('hide'); + addFileToForm(response.link.url); }, error: function(file, errorMessage = 'Attaching the file failed.', xhr) { // If 'error' event is fired by dropzone, the second parameter is error message. @@ -198,6 +199,10 @@ window.DropzoneInput = (function() { return formTextarea.trigger('input'); }; + addFileToForm = function(path) { + $(form).append('<input type="hidden" name="files[]" value="' + _.escape(path) + '">'); + }; + getFilename = function(e) { var value; if (window.clipboardData && window.clipboardData.getData) { diff --git a/app/assets/javascripts/environments/components/environment.vue b/app/assets/javascripts/environments/components/environment.vue index 86d8fe89010..28597c799df 100644 --- a/app/assets/javascripts/environments/components/environment.vue +++ b/app/assets/javascripts/environments/components/environment.vue @@ -109,7 +109,7 @@ export default { eventHub.$on('postAction', this.postAction); }, - beforeDestroyed() { + beforeDestroy() { eventHub.$off('toggleFolder'); eventHub.$off('postAction'); }, @@ -255,7 +255,7 @@ export default { v-if="canCreateEnvironmentParsed" :href="newEnvironmentPath" class="btn btn-create js-new-environment-button"> - New Environment + New environment </a> </div> diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index a2448520a5f..41d5453f1b2 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -70,7 +70,7 @@ export default { </span> </button> - <ul class="dropdown-menu dropdown-menu-align-right"> + <ul class="dropdown-menu"> <li v-for="action in actions"> <button type="button" diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 012ff1f975b..03eb51ba1b2 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -421,14 +421,19 @@ export default { }; </script> <template> - <tr :class="{ 'js-child-row': model.isChildren }"> - <td> + <div + :class="{ 'js-child-row environment-child-row': model.isChildren, 'folder-row': model.isFolder, 'gl-responsive-table-row': !model.isFolder }"> + <div class="table-section section-10" role="gridcell"> + <div + v-if="!model.isFolder" + class="table-mobile-header"> + Environment + </div> <a v-if="!model.isFolder" - class="environment-name" - :class="{ 'prepend-left-default': model.isChildren }" + class="environment-name flex-truncate-parent table-mobile-content" :href="environmentPath"> - {{model.name}} + <span class="flex-truncate-child">{{model.name}}</span> </a> <span v-else @@ -461,9 +466,9 @@ export default { {{model.size}} </span> </span> - </td> + </div> - <td class="deployment-column"> + <div class="table-section section-10 deployment-column hidden-xs hidden-sm" role="gridcell"> <span v-if="shouldRenderDeploymentID"> {{deploymentInternalId}} </span> @@ -478,21 +483,26 @@ export default { :tooltip-text="deploymentUser.username" /> </span> - </td> + </div> - <td class="environments-build-cell"> + <div class="table-section section-15 hidden-xs hidden-sm" role="gridcell"> <a v-if="shouldRenderBuildName" class="build-link" :href="buildPath"> {{buildName}} </a> - </td> + </div> - <td> + <div class="table-section section-25" role="gridcell"> + <div + v-if="!model.isFolder" + class="table-mobile-header"> + Commit + </div> <div v-if="!model.isFolder && hasLastDeploymentKey" - class="js-commit-component"> + class="js-commit-component table-mobile-content"> <commit-component :tag="commitTag" :commit-ref="commitRef" @@ -501,25 +511,30 @@ export default { :title="commitTitle" :author="commitAuthor"/> </div> - <p + <div v-if="!model.isFolder && !hasLastDeploymentKey" class="commit-title"> No deployments yet - </p> - </td> + </div> + </div> - <td> + <div class="table-section section-10" role="gridcell"> + <div + v-if="!model.isFolder" + class="table-mobile-header"> + Updated + </div> <span v-if="!model.isFolder && canShowDate" - class="environment-created-date-timeago"> + class="environment-created-date-timeago table-mobile-content"> {{createdDate}} </span> - </td> + </div> - <td class="environments-actions"> + <div class="table-section section-30 environments-actions table-button-footer" role="gridcell"> <div v-if="!model.isFolder" - class="btn-group pull-right" + class="btn-group environment-action-buttons" role="group"> <actions-component @@ -553,6 +568,6 @@ export default { :retry-url="retryUrl" /> </div> - </td> - </tr> + </div> + </div> </template> diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue index 79c019b3491..07cf92281a0 100644 --- a/app/assets/javascripts/environments/components/environment_monitoring.vue +++ b/app/assets/javascripts/environments/components/environment_monitoring.vue @@ -19,7 +19,7 @@ export default { </script> <template> <a - class="btn monitoring-url has-tooltip" + class="btn monitoring-url has-tooltip hidden-xs hidden-sm" data-container="body" rel="noopener noreferrer nofollow" :href="monitoringUrl" diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue index 2ba985bfe3e..49dba38edfb 100644 --- a/app/assets/javascripts/environments/components/environment_rollback.vue +++ b/app/assets/javascripts/environments/components/environment_rollback.vue @@ -43,7 +43,7 @@ export default { <template> <button type="button" - class="btn" + class="btn hidden-xs hidden-sm" @click="onClick" :disabled="isLoading"> diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue index a904453ffa9..091c543860b 100644 --- a/app/assets/javascripts/environments/components/environment_stop.vue +++ b/app/assets/javascripts/environments/components/environment_stop.vue @@ -47,7 +47,7 @@ export default { <template> <button type="button" - class="btn stop-env-link has-tooltip" + class="btn stop-env-link has-tooltip hidden-xs hidden-sm" data-container="body" @click="onClick" :disabled="isLoading" diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue index c8c1f17d4d8..1ca65a79951 100644 --- a/app/assets/javascripts/environments/components/environment_terminal_button.vue +++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue @@ -29,7 +29,7 @@ export default { </script> <template> <a - class="btn terminal-button has-tooltip" + class="btn terminal-button has-tooltip hidden-xs hidden-sm" data-container="body" :title="title" :aria-label="title" diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 5148a2ae79b..f9262ab85c5 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -45,68 +45,59 @@ export default { }; </script> <template> - <table class="table ci-table"> - <thead> - <tr> - <th class="environments-name"> - Environment - </th> - <th class="environments-deploy"> - Last deployment - </th> - <th class="environments-build"> - Job - </th> - <th class="environments-commit"> - Commit - </th> - <th class="environments-date"> - Updated - </th> - <th class="environments-actions"></th> - </tr> - </thead> - <tbody> - <template - v-for="model in environments" - v-bind:model="model"> - <tr - is="environment-item" - :model="model" - :can-create-deployment="canCreateDeployment" - :can-read-environment="canReadEnvironment" - /> + <div class="ci-table" role="grid"> + <div class="gl-responsive-table-row table-row-header" role="row"> + <div class="table-section section-10 environments-name" role="rowheader"> + Environment + </div> + <div class="table-section section-10 environments-deploy" role="rowheader"> + Deployment + </div> + <div class="table-section section-15 environments-build" role="rowheader"> + Job + </div> + <div class="table-section section-25 environments-commit" role="rowheader"> + Commit + </div> + <div class="table-section section-10 environments-date" role="rowheader"> + Updated + </div> + </div> + <template + v-for="model in environments" + v-bind:model="model"> + <div + is="environment-item" + :model="model" + :can-create-deployment="canCreateDeployment" + :can-read-environment="canReadEnvironment" + /> - <template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0"> - <tr v-if="isLoadingFolderContent"> - <td colspan="6"> - <loading-icon size="2" /> - </td> - </tr> + <template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0"> + <div v-if="isLoadingFolderContent"> + <loading-icon size="2" /> + </div> - <template v-else> - <tr - is="environment-item" - v-for="children in model.children" - :model="children" - :can-create-deployment="canCreateDeployment" - :can-read-environment="canReadEnvironment" - /> + <template v-else> + <div + is="environment-item" + v-for="children in model.children" + :model="children" + :can-create-deployment="canCreateDeployment" + :can-read-environment="canReadEnvironment" + /> - <tr> - <td - colspan="6" - class="text-center"> - <a - :href="folderUrl(model)" - class="btn btn-default"> - Show all - </a> - </td> - </tr> - </template> + <div> + <div class="text-center prepend-top-10"> + <a + :href="folderUrl(model)" + class="btn btn-default"> + Show all + </a> + </div> + </div> </template> </template> - </tbody> - </table> + </template> + </div> </template> diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js index 5c02a7a53d3..ef8fe071012 100644 --- a/app/assets/javascripts/filtered_search/dropdown_utils.js +++ b/app/assets/javascripts/filtered_search/dropdown_utils.js @@ -102,10 +102,13 @@ class DropdownUtils { if (token.classList.contains('js-visual-token')) { const name = token.querySelector('.name'); const value = token.querySelector('.value'); + const valueContainer = token.querySelector('.value-container'); const symbol = value && value.dataset.symbol ? value.dataset.symbol : ''; let valueText = ''; - if (value && value.innerText) { + if (valueContainer && valueContainer.dataset.originalValue) { + valueText = valueContainer.dataset.originalValue; + } else if (value && value.innerText) { valueText = value.innerText; } diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 3be889c684b..8f547bd8f1f 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -77,6 +77,41 @@ class FilteredSearchManager { } } + bindStateEvents() { + this.stateFilters = document.querySelector('.container-fluid .issues-state-filters'); + + if (this.stateFilters) { + this.searchStateWrapper = this.searchState.bind(this); + + this.stateFilters.querySelector('[data-state="opened"]') + .addEventListener('click', this.searchStateWrapper); + this.stateFilters.querySelector('[data-state="closed"]') + .addEventListener('click', this.searchStateWrapper); + this.stateFilters.querySelector('[data-state="all"]') + .addEventListener('click', this.searchStateWrapper); + + this.mergedState = this.stateFilters.querySelector('[data-state="merged"]'); + if (this.mergedState) { + this.mergedState.addEventListener('click', this.searchStateWrapper); + } + } + } + + unbindStateEvents() { + if (this.stateFilters) { + this.stateFilters.querySelector('[data-state="opened"]') + .removeEventListener('click', this.searchStateWrapper); + this.stateFilters.querySelector('[data-state="closed"]') + .removeEventListener('click', this.searchStateWrapper); + this.stateFilters.querySelector('[data-state="all"]') + .removeEventListener('click', this.searchStateWrapper); + + if (this.mergedState) { + this.mergedState.removeEventListener('click', this.searchStateWrapper); + } + } + } + bindEvents() { this.handleFormSubmit = this.handleFormSubmit.bind(this); this.setDropdownWrapper = this.dropdownManager.setDropdown.bind(this.dropdownManager); @@ -105,15 +140,15 @@ class FilteredSearchManager { this.filteredSearchInput.addEventListener('click', this.tokenChange); this.filteredSearchInput.addEventListener('keyup', this.tokenChange); this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper); - this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken); this.tokensContainer.addEventListener('click', this.removeTokenWrapper); - this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper); + this.tokensContainer.addEventListener('click', this.editTokenWrapper); this.clearSearchButton.addEventListener('click', this.onClearSearchWrapper); - document.addEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens); document.addEventListener('click', this.unselectEditTokensWrapper); document.addEventListener('click', this.removeInputContainerFocusWrapper); document.addEventListener('keydown', this.removeSelectedTokenKeydownWrapper); eventHub.$on('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper); + + this.bindStateEvents(); } unbindEvents() { @@ -127,15 +162,15 @@ class FilteredSearchManager { this.filteredSearchInput.removeEventListener('click', this.tokenChange); this.filteredSearchInput.removeEventListener('keyup', this.tokenChange); this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper); - this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken); this.tokensContainer.removeEventListener('click', this.removeTokenWrapper); - this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper); + this.tokensContainer.removeEventListener('click', this.editTokenWrapper); this.clearSearchButton.removeEventListener('click', this.onClearSearchWrapper); - document.removeEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens); document.removeEventListener('click', this.unselectEditTokensWrapper); document.removeEventListener('click', this.removeInputContainerFocusWrapper); document.removeEventListener('keydown', this.removeSelectedTokenKeydownWrapper); eventHub.$off('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper); + + this.unbindStateEvents(); } checkForBackspace(e) { @@ -207,23 +242,13 @@ class FilteredSearchManager { } } - static selectToken(e) { - const button = e.target.closest('.selectable'); - const removeButtonSelected = e.target.closest('.remove-token'); - - if (!removeButtonSelected && button) { - e.preventDefault(); - e.stopPropagation(); - gl.FilteredSearchVisualTokens.selectToken(button); - } - } - removeToken(e) { const removeButtonSelected = e.target.closest('.remove-token'); if (removeButtonSelected) { e.preventDefault(); - e.stopPropagation(); + // Prevent editToken from being triggered after token is removed + e.stopImmediatePropagation(); const button = e.target.closest('.selectable'); gl.FilteredSearchVisualTokens.selectToken(button, true); @@ -245,10 +270,12 @@ class FilteredSearchManager { editToken(e) { const token = e.target.closest('.js-visual-token'); - const sanitizedTokenName = token.querySelector('.name').textContent.trim(); + const sanitizedTokenName = token && token.querySelector('.name').textContent.trim(); const canEdit = this.canEdit && this.canEdit(sanitizedTokenName); if (token && canEdit) { + e.preventDefault(); + e.stopPropagation(); gl.FilteredSearchVisualTokens.editToken(token); this.tokenChange(); } @@ -459,7 +486,19 @@ class FilteredSearchManager { } } - search() { + searchState(e) { + const target = e.currentTarget; + // remove focus outline after click + target.blur(); + + const state = target.dataset && target.dataset.state; + + if (state) { + this.search(state); + } + } + + search(state = null) { const paths = []; const searchQuery = gl.DropdownUtils.getSearchQuery(); @@ -467,7 +506,7 @@ class FilteredSearchManager { const { tokens, searchToken } = this.tokenizer.processTokens(searchQuery, this.filteredSearchTokenKeys.getKeys()); - const currentState = gl.utils.getParameterByName('state') || 'opened'; + const currentState = state || gl.utils.getParameterByName('state') || 'opened'; paths.push(`state=${currentState}`); tokens.forEach((token) => { 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 bc1226f5879..e9278140af0 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -1,6 +1,7 @@ -import AjaxCache from '~/lib/utils/ajax_cache'; -import '~/flash'; /* global Flash */ +import AjaxCache from '../lib/utils/ajax_cache'; +import '../flash'; /* global Flash */ import FilteredSearchContainer from './container'; +import UsersCache from '../lib/utils/users_cache'; class FilteredSearchVisualTokens { static getLastVisualTokenBeforeInput() { @@ -82,12 +83,42 @@ class FilteredSearchVisualTokens { .catch(() => new Flash('An error occurred while fetching label colors.')); } + static updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) { + if (tokenValue === 'none') { + return Promise.resolve(); + } + + const username = tokenValue.replace(/^@/, ''); + return UsersCache.retrieve(username) + .then((user) => { + if (!user) { + return; + } + + /* eslint-disable no-param-reassign */ + tokenValueContainer.dataset.originalValue = tokenValue; + tokenValueElement.innerHTML = ` + <img class="avatar s20" src="${user.avatar_url}" alt="${user.name}'s avatar"> + ${user.name} + `; + /* eslint-enable no-param-reassign */ + }) + // ignore error and leave username in the search bar + .catch(() => { }); + } + static renderVisualTokenValue(parentElement, tokenName, tokenValue) { const tokenValueContainer = parentElement.querySelector('.value-container'); - tokenValueContainer.querySelector('.value').innerText = tokenValue; + const tokenValueElement = tokenValueContainer.querySelector('.value'); + tokenValueElement.innerText = tokenValue; - if (tokenName.toLowerCase() === 'label') { + const tokenType = tokenName.toLowerCase(); + if (tokenType === 'label') { FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue); + } else if ((tokenType === 'author') || (tokenType === 'assignee')) { + FilteredSearchVisualTokens.updateUserTokenAppearance( + tokenValueContainer, tokenValueElement, tokenValue, + ); } } @@ -153,6 +184,12 @@ class FilteredSearchVisualTokens { if (!lastVisualToken) return ''; + const valueContainer = lastVisualToken.querySelector('.value-container'); + const originalValue = valueContainer && valueContainer.dataset.originalValue; + if (originalValue) { + return originalValue; + } + const value = lastVisualToken.querySelector('.value'); const name = lastVisualToken.querySelector('.name'); @@ -205,17 +242,28 @@ class FilteredSearchVisualTokens { const inputLi = input.parentElement; tokenContainer.replaceChild(inputLi, token); - const name = token.querySelector('.name'); - const value = token.querySelector('.value'); + const nameElement = token.querySelector('.name'); + let value; - if (token.classList.contains('filtered-search-token') && value) { - FilteredSearchVisualTokens.addFilterVisualToken(name.innerText); - input.value = value.innerText; - } else { - // token is a search term - input.value = name.innerText; + if (token.classList.contains('filtered-search-token')) { + FilteredSearchVisualTokens.addFilterVisualToken(nameElement.innerText); + + const valueContainerElement = token.querySelector('.value-container'); + value = valueContainerElement.dataset.originalValue; + + if (!value) { + const valueElement = valueContainerElement.querySelector('.value'); + value = valueElement.innerText; + } } + // token is a search term + if (!value) { + value = nameElement.innerText; + } + + input.value = value; + // Opens dropdown const inputEvent = new Event('input'); input.dispatchEvent(inputEvent); diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index eec30624ff2..ccff8f0ace7 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -7,8 +7,21 @@ window.Flash = (function() { return $(this).fadeOut(); }; - function Flash(message, type, parent) { - var flash, textDiv; + /** + * Flash banner supports different types of Flash configurations + * along with ability to provide actionConfig which can be used to show + * additional action or link on banner next to message + * + * @param {String} message Flash message + * @param {String} type Type of Flash, it can be `notice` or `alert` (default) + * @param {Object} parent Reference to Parent element under which Flash needs to appear + * @param {Object} actionConfig Map of config to show action on banner + * @param {String} href URL to which action link should point (default '#') + * @param {String} title Title of action + * @param {Function} clickHandler Method to call when action is clicked on + */ + function Flash(message, type, parent, actionConfig) { + var flash, textDiv, actionLink; if (type == null) { type = 'alert'; } @@ -30,6 +43,23 @@ window.Flash = (function() { text: message }); textDiv.appendTo(flash); + + if (actionConfig) { + const actionLinkConfig = { + class: 'flash-action', + href: actionConfig.href || '#', + text: actionConfig.title + }; + + if (!actionConfig.href) { + actionLinkConfig.role = 'button'; + } + + actionLink = $('<a/>', actionLinkConfig); + + actionLink.appendTo(flash); + this.flashContainer.on('click', '.flash-action', actionConfig.clickHandler); + } if (this.flashContainer.parent().hasClass('content-wrapper')) { textDiv.addClass('container-fluid container-limited'); } diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index b8a923cf619..401dec1a370 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -2,6 +2,7 @@ import emojiMap from 'emojis/digests.json'; import emojiAliases from 'emojis/aliases.json'; import { glEmojiTag } from '~/behaviors/gl_emoji'; import glRegexp from '~/lib/utils/regexp'; +import AjaxCache from '~/lib/utils/ajax_cache'; function sanitize(str) { return str.replace(/<(?:.|\n)*?>/gm, ''); @@ -35,6 +36,7 @@ class GfmAutoComplete { // This triggers at.js again // Needed for slash commands with suffixes (ex: /label ~) $input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup')); + $input.on('clear-commands-cache.atwho', () => this.clearCache()); }); } @@ -375,11 +377,14 @@ class GfmAutoComplete { } else if (GfmAutoComplete.atTypeMap[at] === 'emojis') { this.loadData($input, at, Object.keys(emojiMap).concat(Object.keys(emojiAliases))); } else { - $.getJSON(this.dataSources[GfmAutoComplete.atTypeMap[at]], (data) => { - this.loadData($input, at, data); - }).fail(() => { this.isLoadingData[at] = false; }); + AjaxCache.retrieve(this.dataSources[GfmAutoComplete.atTypeMap[at]], true) + .then((data) => { + this.loadData($input, at, data); + }) + .catch(() => { this.isLoadingData[at] = false; }); } } + loadData($input, at, data) { this.isLoadingData[at] = false; this.cachedData[at] = data; @@ -389,6 +394,10 @@ class GfmAutoComplete { return $input.trigger('keyup'); } + clearCache() { + this.cachedData = {}; + } + static isLoading(data) { let dataToInspect = data; if (data && data.length > 0) { diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js index 4f226ff96ea..4bef60264bb 100644 --- a/app/assets/javascripts/gl_field_errors.js +++ b/app/assets/javascripts/gl_field_errors.js @@ -31,9 +31,13 @@ class GlFieldErrors { * and prevents disabling of invalid submit button by application.js */ catchInvalidFormSubmit (event) { - if (!event.currentTarget.checkValidity()) { - event.preventDefault(); - event.stopPropagation(); + const $form = $(event.currentTarget); + + if (!$form.attr('novalidate')) { + if (!event.currentTarget.checkValidity()) { + event.preventDefault(); + event.stopPropagation(); + } } } diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js index 34e4a257ff9..5b4ca94ed30 100644 --- a/app/assets/javascripts/importer_status.js +++ b/app/assets/javascripts/importer_status.js @@ -56,6 +56,8 @@ if (job.import_status === 'finished') { job_item.removeClass("active").addClass("success"); return status_field.html('<span><i class="fa fa-check"></i> done</span>'); + } else if (job.import_status === 'scheduled') { + return status_field.html("<i class='fa fa-spinner fa-spin'></i> scheduled"); } else if (job.import_status === 'started') { return status_field.html("<i class='fa fa-spinner fa-spin'></i> started"); } else { diff --git a/app/assets/javascripts/integrations/index.js b/app/assets/javascripts/integrations/index.js new file mode 100644 index 00000000000..10fe6bac0e8 --- /dev/null +++ b/app/assets/javascripts/integrations/index.js @@ -0,0 +1,7 @@ +/* eslint-disable no-new */ +import IntegrationSettingsForm from './integration_settings_form'; + +$(() => { + const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + integrationSettingsForm.init(); +}); diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js new file mode 100644 index 00000000000..ddd3a6aab99 --- /dev/null +++ b/app/assets/javascripts/integrations/integration_settings_form.js @@ -0,0 +1,123 @@ +/* global Flash */ + +export default class IntegrationSettingsForm { + constructor(formSelector) { + this.$form = $(formSelector); + + // Form Metadata + this.canTestService = this.$form.data('can-test'); + this.testEndPoint = this.$form.data('test-url'); + + // Form Child Elements + this.$serviceToggle = this.$form.find('#service_active'); + this.$submitBtn = this.$form.find('button[type="submit"]'); + this.$submitBtnLoader = this.$submitBtn.find('.js-btn-spinner'); + this.$submitBtnLabel = this.$submitBtn.find('.js-btn-label'); + } + + init() { + // Initialize View + this.toggleServiceState(this.$serviceToggle.is(':checked')); + + // Bind Event Listeners + this.$serviceToggle.on('change', e => this.handleServiceToggle(e)); + this.$submitBtn.on('click', e => this.handleSettingsSave(e)); + } + + handleSettingsSave(e) { + // Check if Service is marked active, as if not marked active, + // We can skip testing it and directly go ahead to allow form to + // be submitted + if (!this.$serviceToggle.is(':checked')) { + return; + } + + // Service was marked active so now we check; + // 1) If form contents are valid + // 2) If this service can be tested + // If both conditions are true, we override form submission + // and test the service using provided configuration. + if (this.$form.get(0).checkValidity() && this.canTestService) { + e.preventDefault(); + this.testSettings(this.$form.serialize()); + } + } + + handleServiceToggle(e) { + this.toggleServiceState($(e.currentTarget).is(':checked')); + } + + /** + * Change Form's validation enforcement based on service status (active/inactive) + */ + toggleServiceState(serviceActive) { + this.toggleSubmitBtnLabel(serviceActive); + if (serviceActive) { + this.$form.removeAttr('novalidate'); + } else if (!this.$form.attr('novalidate')) { + this.$form.attr('novalidate', 'novalidate'); + } + } + + /** + * Toggle Submit button label based on Integration status and ability to test service + */ + toggleSubmitBtnLabel(serviceActive) { + let btnLabel = 'Save changes'; + + if (serviceActive && this.canTestService) { + btnLabel = 'Test settings and save changes'; + } + + this.$submitBtnLabel.text(btnLabel); + } + + /** + * Toggle Submit button state based on provided boolean value of `saveTestActive` + * When enabled, it does two things, and reverts back when disabled + * + * 1. It shows load spinner on submit button + * 2. Makes submit button disabled + */ + toggleSubmitBtnState(saveTestActive) { + if (saveTestActive) { + this.$submitBtn.disable(); + this.$submitBtnLoader.removeClass('hidden'); + } else { + this.$submitBtn.enable(); + this.$submitBtnLoader.addClass('hidden'); + } + } + + /* eslint-disable promise/catch-or-return, no-new */ + /** + * Test Integration config + */ + testSettings(formData) { + this.toggleSubmitBtnState(true); + $.ajax({ + type: 'PUT', + url: this.testEndPoint, + data: formData, + }) + .done((res) => { + if (res.error) { + new Flash(res.message, null, null, { + title: 'Save anyway', + clickHandler: (e) => { + e.preventDefault(); + this.$form.submit(); + }, + }); + } else { + this.$form.submit(); + } + }) + .fail(() => { + new Flash('Something went wrong on our end.'); + }) + .always(() => { + this.toggleSubmitBtnState(false); + }); + } +} diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js new file mode 100644 index 00000000000..e46c0e90255 --- /dev/null +++ b/app/assets/javascripts/issuable_bulk_update_actions.js @@ -0,0 +1,159 @@ +/* eslint-disable comma-dangle, quotes, consistent-return, func-names, array-callback-return, space-before-function-paren, prefer-arrow-callback, max-len, no-unused-expressions, no-sequences, no-underscore-dangle, no-unused-vars, no-param-reassign */ +/* global IssuableIndex */ +/* global Flash */ + +export default { + init({ container, form, issues, prefixId } = {}) { + this.prefixId = prefixId || 'issue_'; + this.form = form || this.getElement('.bulk-update'); + this.$labelDropdown = this.form.find('.js-label-select'); + this.issues = issues || this.getElement('.issues-list .issue'); + this.willUpdateLabels = false; + this.bindEvents(); + }, + + bindEvents() { + return this.form.off('submit').on('submit', this.onFormSubmit.bind(this)); + }, + + onFormSubmit(e) { + e.preventDefault(); + return this.submit(); + }, + + submit() { + const _this = this; + const xhr = $.ajax({ + url: this.form.attr('action'), + method: this.form.attr('method'), + dataType: 'JSON', + data: this.getFormDataAsObject() + }); + xhr.done(() => window.location.reload()); + xhr.fail(() => this.onFormSubmitFailure()); + }, + + onFormSubmitFailure() { + this.form.find('[type="submit"]').enable(); + return new Flash("Issue update failed"); + }, + + getSelectedIssues() { + return this.issues.has('.selected_issue:checked'); + }, + + getLabelsFromSelection() { + const labels = []; + this.getSelectedIssues().map(function() { + const labelsData = $(this).data('labels'); + if (labelsData) { + return labelsData.map(function(labelId) { + if (labels.indexOf(labelId) === -1) { + return labels.push(labelId); + } + }); + } + }); + return labels; + }, + + /** + * Will return only labels that were marked previously and the user has unmarked + * @return {Array} Label IDs + */ + + getUnmarkedIndeterminedLabels() { + const result = []; + const labelsToKeep = this.$labelDropdown.data('indeterminate'); + + this.getLabelsFromSelection().forEach((id) => { + if (labelsToKeep.indexOf(id) === -1) { + result.push(id); + } + }); + + return result; + }, + + /** + * Simple form serialization, it will return just what we need + * Returns key/value pairs from form data + */ + + getFormDataAsObject() { + const formData = { + update: { + state_event: this.form.find('input[name="update[state_event]"]').val(), + // For Merge Requests + assignee_id: this.form.find('input[name="update[assignee_id]"]').val(), + // For Issues + assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()], + milestone_id: this.form.find('input[name="update[milestone_id]"]').val(), + issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(), + subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), + add_label_ids: [], + remove_label_ids: [] + } + }; + if (this.willUpdateLabels) { + formData.update.add_label_ids = this.$labelDropdown.data('marked'); + formData.update.remove_label_ids = this.$labelDropdown.data('unmarked'); + } + return formData; + }, + + setOriginalDropdownData() { + const $labelSelect = $('.bulk-update .js-label-select'); + $labelSelect.data('common', this.getOriginalCommonIds()); + $labelSelect.data('marked', this.getOriginalMarkedIds()); + $labelSelect.data('indeterminate', this.getOriginalIndeterminateIds()); + }, + + // From issuable's initial bulk selection + getOriginalCommonIds() { + const labelIds = []; + + this.getElement('.selected_issue:checked').each((i, el) => { + labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); + }); + return _.intersection.apply(this, labelIds); + }, + + // From issuable's initial bulk selection + getOriginalMarkedIds() { + const labelIds = []; + this.getElement('.selected_issue:checked').each((i, el) => { + labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); + }); + return _.intersection.apply(this, labelIds); + }, + + // From issuable's initial bulk selection + getOriginalIndeterminateIds() { + const uniqueIds = []; + const labelIds = []; + let issuableLabels = []; + + // Collect unique label IDs for all checked issues + this.getElement('.selected_issue:checked').each((i, el) => { + issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'); + issuableLabels.forEach((labelId) => { + // Store unique IDs + if (uniqueIds.indexOf(labelId) === -1) { + uniqueIds.push(labelId); + } + }); + // Store array of IDs per issuable + labelIds.push(issuableLabels); + }); + // Add uniqueIds to add it as argument for _.intersection + labelIds.unshift(uniqueIds); + // Return IDs that are present but not in all selected issueables + return _.difference(uniqueIds, _.intersection.apply(this, labelIds)); + }, + + getElement(selector) { + this.scopeEl = this.scopeEl || $('.content'); + return this.scopeEl.find(selector); + }, +}; diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js new file mode 100644 index 00000000000..84bd2e092e6 --- /dev/null +++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js @@ -0,0 +1,165 @@ +/* eslint-disable class-methods-use-this, no-new */ +/* global LabelsSelect */ +/* global MilestoneSelect */ +/* global IssueStatusSelect */ +/* global SubscriptionSelect */ + +import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; + +const HIDDEN_CLASS = 'hidden'; +const DISABLED_CONTENT_CLASS = 'disabled-content'; +const SIDEBAR_EXPANDED_CLASS = 'right-sidebar-expanded issuable-bulk-update-sidebar'; +const SIDEBAR_COLLAPSED_CLASS = 'right-sidebar-collapsed issuable-bulk-update-sidebar'; + +export default class IssuableBulkUpdateSidebar { + constructor() { + this.initDomElements(); + this.bindEvents(); + this.initDropdowns(); + this.setupBulkUpdateActions(); + } + + initDomElements() { + this.$page = $('.page-with-sidebar'); + this.$sidebar = $('.right-sidebar'); + this.$bulkEditCancelBtn = $('.js-bulk-update-menu-hide'); + this.$bulkEditSubmitBtn = $('.update-selected-issues'); + this.$bulkUpdateEnableBtn = $('.js-bulk-update-toggle'); + this.$otherFilters = $('.issues-other-filters'); + this.$checkAllContainer = $('.check-all-holder'); + this.$issueChecks = $('.issue-check'); + this.$issuesList = $('.selected_issue'); + this.$issuableIdsInput = $('#update_issuable_ids'); + } + + bindEvents() { + this.$bulkUpdateEnableBtn.on('click', e => this.toggleBulkEdit(e, true)); + this.$bulkEditCancelBtn.on('click', e => this.toggleBulkEdit(e, false)); + this.$checkAllContainer.on('click', e => this.selectAll(e)); + this.$issuesList.on('change', () => this.updateFormState()); + this.$bulkEditSubmitBtn.on('click', () => this.prepForSubmit()); + this.$checkAllContainer.on('click', () => this.updateFormState()); + } + + initDropdowns() { + new LabelsSelect(); + new MilestoneSelect(); + new IssueStatusSelect(); + new SubscriptionSelect(); + } + + getNavHeight() { + const navbarHeight = $('.navbar-gitlab').outerHeight(); + const layoutNavHeight = $('.layout-nav').outerHeight(); + const subNavScroll = $('.sub-nav-scroll').outerHeight(); + return navbarHeight + layoutNavHeight + subNavScroll; + } + + initSidebar() { + if (!this.navHeight) { + this.navHeight = this.getNavHeight(); + } + + if (!this.sidebarInitialized) { + $(document).off('scroll').on('scroll', _.throttle(this.setSidebarHeight, 10).bind(this)); + $(window).off('resize').on('resize', _.throttle(this.setSidebarHeight, 10).bind(this)); + this.sidebarInitialized = true; + } + } + + setupBulkUpdateActions() { + IssuableBulkUpdateActions.setOriginalDropdownData(); + } + + updateFormState() { + const noCheckedIssues = !$('.selected_issue:checked').length; + + this.toggleSubmitButtonDisabled(noCheckedIssues); + this.updateSelectedIssuableIds(); + + IssuableBulkUpdateActions.setOriginalDropdownData(); + } + + prepForSubmit() { + // if submit button is disabled, submission is blocked. This ensures we disable after + // form submission is carried out + setTimeout(() => this.$bulkEditSubmitBtn.disable()); + this.updateSelectedIssuableIds(); + } + + toggleBulkEdit(e, enable) { + e.preventDefault(); + + this.toggleSidebarDisplay(enable); + this.toggleBulkEditButtonDisabled(enable); + this.toggleOtherFiltersDisabled(enable); + this.toggleCheckboxDisplay(enable); + + if (enable) { + this.initSidebar(); + } + } + + updateSelectedIssuableIds() { + this.$issuableIdsInput.val(IssuableBulkUpdateSidebar.getCheckedIssueIds()); + } + + selectAll() { + const checkAllButtonState = this.$checkAllContainer.find('input').prop('checked'); + + this.$issuesList.prop('checked', checkAllButtonState); + } + + toggleSidebarDisplay(show) { + this.$page.toggleClass(SIDEBAR_EXPANDED_CLASS, show); + this.$page.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show); + this.$sidebar.toggleClass(SIDEBAR_EXPANDED_CLASS, show); + this.$sidebar.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show); + } + + toggleBulkEditButtonDisabled(disable) { + if (disable) { + this.$bulkUpdateEnableBtn.disable(); + } else { + this.$bulkUpdateEnableBtn.enable(); + } + } + + toggleCheckboxDisplay(show) { + this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show); + this.$issueChecks.toggleClass(HIDDEN_CLASS, !show); + } + + toggleOtherFiltersDisabled(disable) { + this.$otherFilters.toggleClass(DISABLED_CONTENT_CLASS, disable); + } + + toggleSubmitButtonDisabled(disable) { + if (disable) { + this.$bulkEditSubmitBtn.disable(); + } else { + this.$bulkEditSubmitBtn.enable(); + } + } + // loosely based on method of the same name in right_sidebar.js + setSidebarHeight() { + const currentScrollDepth = window.pageYOffset || 0; + const diff = this.navHeight - currentScrollDepth; + + if (diff > 0) { + this.$sidebar.outerHeight(window.innerHeight - diff); + } else { + this.$sidebar.outerHeight('100%'); + } + } + + static getCheckedIssueIds() { + const $checkedIssues = $('.selected_issue:checked'); + + if ($checkedIssues.length > 0) { + return $.map($checkedIssues, value => $(value).data('id')); + } + + return []; + } +} diff --git a/app/assets/javascripts/issuable.js b/app/assets/javascripts/issuable_index.js index 3bfce32768a..5c96646def8 100644 --- a/app/assets/javascripts/issuable.js +++ b/app/assets/javascripts/issuable_index.js @@ -1,30 +1,33 @@ /* eslint-disable no-param-reassign, func-names, no-var, camelcase, no-unused-vars, object-shorthand, space-before-function-paren, no-return-assign, comma-dangle, consistent-return, one-var, one-var-declaration-per-line, quotes, prefer-template, prefer-arrow-callback, wrap-iife, max-len */ -/* global Issuable */ +/* global IssuableIndex */ + +import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar'; +import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; ((global) => { var issuable_created; issuable_created = false; - global.Issuable = { - init: function() { - Issuable.initTemplates(); - Issuable.initSearch(); - Issuable.initChecks(); - Issuable.initResetFilters(); - Issuable.resetIncomingEmailToken(); - return Issuable.initLabelFilterRemove(); + global.IssuableIndex = { + init: function(pagePrefix) { + IssuableIndex.initTemplates(); + IssuableIndex.initSearch(); + IssuableIndex.initBulkUpdate(pagePrefix); + IssuableIndex.initResetFilters(); + IssuableIndex.resetIncomingEmailToken(); + IssuableIndex.initLabelFilterRemove(); }, initTemplates: function() { - return Issuable.labelRow = _.template('<% _.each(labels, function(label){ %> <span class="label-row btn-group" role="group" aria-label="<%- label.title %>" style="color: <%- label.text_color %>;"> <a href="#" class="btn btn-transparent has-tooltip" style="background-color: <%- label.color %>;" title="<%- label.description %>" data-container="body"> <%- label.title %> </a> <button type="button" class="btn btn-transparent label-remove js-label-filter-remove" style="background-color: <%- label.color %>;" data-label="<%- label.title %>"> <i class="fa fa-times"></i> </button> </span> <% }); %>'); + return IssuableIndex.labelRow = _.template('<% _.each(labels, function(label){ %> <span class="label-row btn-group" role="group" aria-label="<%- label.title %>" style="color: <%- label.text_color %>;"> <a href="#" class="btn btn-transparent has-tooltip" style="background-color: <%- label.color %>;" title="<%- label.description %>" data-container="body"> <%- label.title %> </a> <button type="button" class="btn btn-transparent label-remove js-label-filter-remove" style="background-color: <%- label.color %>;" data-label="<%- label.title %>"> <i class="fa fa-times"></i> </button> </span> <% }); %>'); }, initSearch: function() { const $searchInput = $('#issuable_search'); - Issuable.initSearchState($searchInput); + IssuableIndex.initSearchState($searchInput); // `immediate` param set to false debounces on the `trailing` edge, lets user finish typing - const debouncedExecSearch = _.debounce(Issuable.executeSearch, 1000, false); + const debouncedExecSearch = _.debounce(IssuableIndex.executeSearch, 1000, false); $searchInput.off('keyup').on('keyup', debouncedExecSearch); @@ -37,16 +40,16 @@ initSearchState: function($searchInput) { const currentSearchVal = $searchInput.val(); - Issuable.searchState = { + IssuableIndex.searchState = { elem: $searchInput, current: currentSearchVal }; - Issuable.maybeFocusOnSearch(); + IssuableIndex.maybeFocusOnSearch(); }, accessSearchPristine: function(set) { // store reference to previous value to prevent search on non-mutating keyup - const state = Issuable.searchState; + const state = IssuableIndex.searchState; const currentSearchVal = state.elem.val(); if (set) { @@ -56,10 +59,10 @@ } }, maybeFocusOnSearch: function() { - const currentSearchVal = Issuable.searchState.current; + const currentSearchVal = IssuableIndex.searchState.current; if (currentSearchVal && currentSearchVal !== '') { const queryLength = currentSearchVal.length; - const $searchInput = Issuable.searchState.elem; + const $searchInput = IssuableIndex.searchState.elem; /* The following ensures that the cursor is initially placed at * the end of search input when focus is applied. It accounts @@ -80,7 +83,7 @@ const $searchValue = $search.val(); const $filtersForm = $('.js-filter-form'); const $input = $(`input[name='${$searchName}']`, $filtersForm); - const isPristine = Issuable.accessSearchPristine(); + const isPristine = IssuableIndex.accessSearchPristine(); if (isPristine) { return; @@ -92,7 +95,7 @@ $input.val($searchValue); } - Issuable.filterResults($filtersForm); + IssuableIndex.filterResults($filtersForm); }, initLabelFilterRemove: function() { return $(document).off('click', '.js-label-filter-remove').on('click', '.js-label-filter-remove', function(e) { @@ -103,7 +106,7 @@ return this.value === $button.data('label'); }).remove(); // Submit the form to get new data - Issuable.filterResults($('.filter-form')); + IssuableIndex.filterResults($('.filter-form')); }); }, filterResults: (function(_this) { @@ -132,38 +135,18 @@ gl.utils.visitUrl(baseIssuesUrl); }); }, - initChecks: function() { - this.issuableBulkActions = $('.bulk-update').data('bulkActions'); - $('.check_all_issues').off('click').on('click', function() { - $('.selected_issue').prop('checked', this.checked); - return Issuable.checkChanged(); - }); - return $('.selected_issue').off('change').on('change', Issuable.checkChanged.bind(this)); - }, - checkChanged: function() { - const $checkedIssues = $('.selected_issue:checked'); - const $updateIssuesIds = $('#update_issuable_ids'); - const $issuesOtherFilters = $('.issues-other-filters'); - const $issuesBulkUpdate = $('.issues_bulk_update'); - - this.issuableBulkActions.willUpdateLabels = false; - this.issuableBulkActions.setOriginalDropdownData(); - - if ($checkedIssues.length > 0) { - const ids = $.map($checkedIssues, function(value) { - return $(value).data('id'); + initBulkUpdate: function(pagePrefix) { + const userCanBulkUpdate = $('.issues-bulk-update').length > 0; + const alreadyInitialized = !!this.bulkUpdateSidebar; + + if (userCanBulkUpdate && !alreadyInitialized) { + IssuableBulkUpdateActions.init({ + prefixId: pagePrefix, }); - $updateIssuesIds.val(ids); - $issuesOtherFilters.hide(); - $issuesBulkUpdate.show(); - } else { - $updateIssuesIds.val([]); - $issuesBulkUpdate.hide(); - $issuesOtherFilters.show(); + + this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar(); } - return true; }, - resetIncomingEmailToken: function() { $('.incoming-email-token-reset').on('click', function(e) { e.preventDefault(); diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index 800bb9f1fe8..e14414d3f68 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -7,6 +7,7 @@ import Service from '../services/index'; import Store from '../stores'; import titleComponent from './title.vue'; import descriptionComponent from './description.vue'; +import editedComponent from './edited.vue'; import formComponent from './form.vue'; import '../../lib/utils/url_utility'; @@ -50,6 +51,21 @@ export default { required: false, default: '', }, + updatedAt: { + type: String, + required: false, + default: '', + }, + updatedByName: { + type: String, + required: false, + default: '', + }, + updatedByPath: { + type: String, + required: false, + default: '', + }, issuableTemplates: { type: Array, required: false, @@ -86,6 +102,9 @@ export default { titleText: this.initialTitleText, descriptionHtml: this.initialDescriptionHtml, descriptionText: this.initialDescriptionText, + updatedAt: this.updatedAt, + updatedByName: this.updatedByName, + updatedByPath: this.updatedByPath, }); return { @@ -98,10 +117,14 @@ export default { formState() { return this.store.formState; }, + hasUpdated() { + return !!this.state.updatedAt; + }, }, components: { descriptionComponent, titleComponent, + editedComponent, formComponent, }, methods: { @@ -240,6 +263,11 @@ export default { :description-text="state.descriptionText" :updated-at="state.updatedAt" :task-status="state.taskStatus" /> + <edited-component + v-if="hasUpdated" + :updated-at="state.updatedAt" + :updated-by-name="state.updatedByName" + :updated-by-path="state.updatedByPath" /> </div> </div> </template> diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index 3281ec6b172..5ae617356e0 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -16,11 +16,6 @@ type: String, required: true, }, - updatedAt: { - type: String, - required: false, - default: '', - }, taskStatus: { type: String, required: false, @@ -31,7 +26,6 @@ return { preAnimation: false, pulseAnimation: false, - timeAgoEl: $('.js-issue-edited-ago'), }; }, watch: { @@ -39,12 +33,6 @@ this.animateChange(); this.$nextTick(() => { - const toolTipTime = gl.utils.formatDate(this.updatedAt); - - this.timeAgoEl.attr('datetime', this.updatedAt) - .attr('title', toolTipTime) - .tooltip('fixTitle'); - this.renderGFM(); }); }, diff --git a/app/assets/javascripts/issue_show/components/edited.vue b/app/assets/javascripts/issue_show/components/edited.vue new file mode 100644 index 00000000000..d59e6d11032 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/edited.vue @@ -0,0 +1,56 @@ +<script> +import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; + +export default { + props: { + updatedAt: { + type: String, + required: false, + default: '', + }, + updatedByName: { + type: String, + required: false, + default: '', + }, + updatedByPath: { + type: String, + required: false, + default: '', + }, + }, + components: { + timeAgoTooltip, + }, + computed: { + hasUpdatedBy() { + return this.updatedByName && this.updatedByPath; + }, + }, +}; +</script> + +<template> + <small + class="edited-text" + > + Edited + <time-ago-tooltip + v-if="updatedAt" + placement="bottom" + :time="updatedAt" + /> + <span + v-if="hasUpdatedBy" + > + by + <a + class="author_link" + :href="updatedByPath" + > + <span>{{updatedByName}}</span> + </a> + </span> + </small> +</template> + diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index faf79471946..14b2a1e18e9 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -42,6 +42,9 @@ document.addEventListener('DOMContentLoaded', () => { projectPath: this.projectPath, projectNamespace: this.projectNamespace, projectsAutocompleteUrl: this.projectsAutocompleteUrl, + updatedAt: this.updatedAt, + updatedByName: this.updatedByName, + updatedByPath: this.updatedByPath, }, }); }, diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js index 4a16c3cb4dc..27c2d349f52 100644 --- a/app/assets/javascripts/issue_show/stores/index.js +++ b/app/assets/javascripts/issue_show/stores/index.js @@ -4,6 +4,9 @@ export default class Store { titleText, descriptionHtml, descriptionText, + updatedAt, + updatedByName, + updatedByPath, }) { this.state = { titleHtml, @@ -11,7 +14,9 @@ export default class Store { descriptionHtml, descriptionText, taskStatus: '', - updatedAt: '', + updatedAt, + updatedByName, + updatedByPath, }; this.formState = { title: '', @@ -30,6 +35,8 @@ export default class Store { this.state.descriptionText = data.description_text; this.state.taskStatus = data.task_status; this.state.updatedAt = data.updated_at; + this.state.updatedByName = data.updated_by_name; + this.state.updatedByPath = data.updated_by_path; } stateShouldUpdate(data) { diff --git a/app/assets/javascripts/issues_bulk_assignment.js b/app/assets/javascripts/issues_bulk_assignment.js deleted file mode 100644 index fee3429e2b8..00000000000 --- a/app/assets/javascripts/issues_bulk_assignment.js +++ /dev/null @@ -1,166 +0,0 @@ -/* eslint-disable comma-dangle, quotes, consistent-return, func-names, array-callback-return, space-before-function-paren, prefer-arrow-callback, max-len, no-unused-expressions, no-sequences, no-underscore-dangle, no-unused-vars, no-param-reassign */ -/* global Issuable */ -/* global Flash */ - -((global) => { - class IssuableBulkActions { - constructor({ container, form, issues, prefixId } = {}) { - this.prefixId = prefixId || 'issue_'; - this.form = form || this.getElement('.bulk-update'); - this.$labelDropdown = this.form.find('.js-label-select'); - this.issues = issues || this.getElement('.issues-list .issue'); - this.form.data('bulkActions', this); - this.willUpdateLabels = false; - this.bindEvents(); - // Fixes bulk-assign not working when navigating through pages - Issuable.initChecks(); - } - - bindEvents() { - return this.form.off('submit').on('submit', this.onFormSubmit.bind(this)); - } - - onFormSubmit(e) { - e.preventDefault(); - return this.submit(); - } - - submit() { - const _this = this; - const xhr = $.ajax({ - url: this.form.attr('action'), - method: this.form.attr('method'), - dataType: 'JSON', - data: this.getFormDataAsObject() - }); - xhr.done(() => window.location.reload()); - xhr.fail(() => new Flash("Issue update failed")); - return xhr.always(this.onFormSubmitAlways.bind(this)); - } - - onFormSubmitAlways() { - return this.form.find('[type="submit"]').enable(); - } - - getSelectedIssues() { - return this.issues.has('.selected_issue:checked'); - } - - getLabelsFromSelection() { - const labels = []; - this.getSelectedIssues().map(function() { - const labelsData = $(this).data('labels'); - if (labelsData) { - return labelsData.map(function(labelId) { - if (labels.indexOf(labelId) === -1) { - return labels.push(labelId); - } - }); - } - }); - return labels; - } - - /** - * Will return only labels that were marked previously and the user has unmarked - * @return {Array} Label IDs - */ - - getUnmarkedIndeterminedLabels() { - const result = []; - const labelsToKeep = this.$labelDropdown.data('indeterminate'); - - this.getLabelsFromSelection().forEach((id) => { - if (labelsToKeep.indexOf(id) === -1) { - result.push(id); - } - }); - - return result; - } - - /** - * Simple form serialization, it will return just what we need - * Returns key/value pairs from form data - */ - - getFormDataAsObject() { - const formData = { - update: { - state_event: this.form.find('input[name="update[state_event]"]').val(), - // For Merge Requests - assignee_id: this.form.find('input[name="update[assignee_id]"]').val(), - // For Issues - assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()], - milestone_id: this.form.find('input[name="update[milestone_id]"]').val(), - issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(), - subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), - add_label_ids: [], - remove_label_ids: [] - } - }; - if (this.willUpdateLabels) { - formData.update.add_label_ids = this.$labelDropdown.data('marked'); - formData.update.remove_label_ids = this.$labelDropdown.data('unmarked'); - } - return formData; - } - - setOriginalDropdownData() { - const $labelSelect = $('.bulk-update .js-label-select'); - $labelSelect.data('common', this.getOriginalCommonIds()); - $labelSelect.data('marked', this.getOriginalMarkedIds()); - $labelSelect.data('indeterminate', this.getOriginalIndeterminateIds()); - } - - // From issuable's initial bulk selection - getOriginalCommonIds() { - const labelIds = []; - - this.getElement('.selected_issue:checked').each((i, el) => { - labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); - }); - return _.intersection.apply(this, labelIds); - } - - // From issuable's initial bulk selection - getOriginalMarkedIds() { - const labelIds = []; - this.getElement('.selected_issue:checked').each((i, el) => { - labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); - }); - return _.intersection.apply(this, labelIds); - } - - // From issuable's initial bulk selection - getOriginalIndeterminateIds() { - const uniqueIds = []; - const labelIds = []; - let issuableLabels = []; - - // Collect unique label IDs for all checked issues - this.getElement('.selected_issue:checked').each((i, el) => { - issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'); - issuableLabels.forEach((labelId) => { - // Store unique IDs - if (uniqueIds.indexOf(labelId) === -1) { - uniqueIds.push(labelId); - } - }); - // Store array of IDs per issuable - labelIds.push(issuableLabels); - }); - // Add uniqueIds to add it as argument for _.intersection - labelIds.unshift(uniqueIds); - // Return IDs that are present but not in all selected issueables - return _.difference(uniqueIds, _.intersection.apply(this, labelIds)); - } - - getElement(selector) { - this.scopeEl = this.scopeEl || $('.content'); - return this.scopeEl.find(selector); - } - } - - global.IssuableBulkActions = IssuableBulkActions; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index ac5ce84e31b..8d7d3d73571 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -2,6 +2,8 @@ /* global Issuable */ /* global ListLabel */ +import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; + (function() { this.LabelsSelect = (function() { function LabelsSelect(els) { @@ -430,20 +432,15 @@ if ($('.selected_issue:checked').length) { return; } - return $('.issues_bulk_update .labels-filter .dropdown-toggle-text').text('Label'); + return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text('Label'); }; LabelsSelect.prototype.enableBulkLabelDropdown = function() { - var issuableBulkActions; - if ($('.selected_issue:checked').length) { - issuableBulkActions = $('.bulk-update').data('bulkActions'); - return issuableBulkActions.willUpdateLabels = true; - } + IssuableBulkUpdateActions.willUpdateLabels = true; }; LabelsSelect.prototype.setDropdownData = function($dropdown, isMarking, value) { var i, markedIds, unmarkedIds, indeterminateIds; - var issuableBulkActions = $('.bulk-update').data('bulkActions'); markedIds = $dropdown.data('marked') || []; unmarkedIds = $dropdown.data('unmarked') || []; @@ -469,13 +466,13 @@ } // If an indeterminate item is being unmarked - if (issuableBulkActions.getOriginalIndeterminateIds().indexOf(value) > -1) { + if (IssuableBulkUpdateActions.getOriginalIndeterminateIds().indexOf(value) > -1) { unmarkedIds.push(value); } // If a marked item is being unmarked // (a marked item could also be a label that is present in all selection) - if (issuableBulkActions.getOriginalCommonIds().indexOf(value) > -1) { + if (IssuableBulkUpdateActions.getOriginalCommonIds().indexOf(value) > -1) { unmarkedIds.push(value); } } diff --git a/app/assets/javascripts/lib/utils/ajax_cache.js b/app/assets/javascripts/lib/utils/ajax_cache.js index f1fe95e12e8..7477b5a5214 100644 --- a/app/assets/javascripts/lib/utils/ajax_cache.js +++ b/app/assets/javascripts/lib/utils/ajax_cache.js @@ -6,8 +6,8 @@ class AjaxCache extends Cache { this.pendingRequests = { }; } - retrieve(endpoint) { - if (this.hasData(endpoint)) { + retrieve(endpoint, forceRetrieve) { + if (this.hasData(endpoint) && !forceRetrieve) { return Promise.resolve(this.get(endpoint)); } diff --git a/app/assets/javascripts/locale/zh_CN/app.js b/app/assets/javascripts/locale/zh_CN/app.js new file mode 100644 index 00000000000..9525bc88190 --- /dev/null +++ b/app/assets/javascripts/locale/zh_CN/app.js @@ -0,0 +1 @@ +var locales = locales || {}; locales['zh_CN'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-05-04 19:24-0500","PO-Revision-Date":"2017-05-04 19:24-0500","Last-Translator":"HuangTao <htve@outlook.com>, 2017","Language-Team":"Chinese (China) (https://www.transifex.com/gitlab-zh/teams/75177/zh_CN/)","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Language":"zh_CN","Plural-Forms":"nplurals=1; plural=0;","lang":"zh_CN","domain":"app","plural_forms":"nplurals=1; plural=0;"},"ByAuthor|by":["作者:"],"Commit":["提交"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["周期分析概述了项目从想法到产品实现的各阶段所需的时间。"],"CycleAnalyticsStage|Code":["编码"],"CycleAnalyticsStage|Issue":["议题"],"CycleAnalyticsStage|Plan":["计划"],"CycleAnalyticsStage|Production":["生产"],"CycleAnalyticsStage|Review":["评审"],"CycleAnalyticsStage|Staging":["预发布"],"CycleAnalyticsStage|Test":["测试"],"Deploy":["部署"],"FirstPushedBy|First":["首次推送"],"FirstPushedBy|pushed by":["推送者:"],"From issue creation until deploy to production":["从创建议题到部署至生产环境"],"From merge request merge until deploy to production":["从合并请求被合并后到部署至生产环境"],"Introducing Cycle Analytics":["周期分析简介"],"Last %d day":["最后 %d 天"],"Limited to showing %d event at most":["最多显示 %d 个事件"],"Median":["中位数"],"New Issue":["新议题"],"Not available":["数据不足"],"Not enough data":["数据不足"],"OpenedNDaysAgo|Opened":["开始于"],"Pipeline Health":["流水线健康指标"],"ProjectLifecycle|Stage":["项目生命周期"],"Read more":["了解更多"],"Related Commits":["相关的提交"],"Related Deployed Jobs":["相关的部署作业"],"Related Issues":["相关的议题"],"Related Jobs":["相关的作业"],"Related Merge Requests":["相关的合并请求"],"Related Merged Requests":["相关已合并的合并请求"],"Showing %d event":["显示 %d 个事件"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["编码阶段概述了从第一次提交到创建合并请求的时间。创建第一个合并请求后,数据将自动添加到此处。"],"The collection of events added to the data gathered for that stage.":["与该阶段相关的事件。"],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["议题阶段概述了从创建议题到将议题设置里程碑或将议题添加到议题看板的时间。开始创建议题以查看此阶段的数据。"],"The phase of the development lifecycle.":["项目生命周期中的各个阶段。"],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["计划阶段概述了从议题添加到日程后到推送首次提交的时间。当首次推送提交后,数据将自动添加到此处。"],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["生产阶段概述了从创建一个议题到将代码部署到生产环境的总时间。当完成想法到部署生产的循环,数据将自动添加到此处。"],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["评审阶段概述了从创建合并请求到被合并的时间。当创建第一个合并请求后,数据将自动添加到此处。"],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["预发布阶段概述了从合并请求被合并到部署至生产环境的总时间。首次部署到生产环境后,数据将自动添加到此处。"],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["测试阶段概述了GitLab CI为相关合并请求运行每个流水线所需的时间。当第一个流水线运行完成后,数据将自动添加到此处。"],"The time taken by each data entry gathered by that stage.":["该阶段每条数据所花的时间"],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["中位数是一个数列中最中间的值。例如在 3、5、9 之间,中位数是 5。在 3、5、7、8 之间,中位数是 (5 + 7)/ 2 = 6。"],"Time before an issue gets scheduled":["议题被列入日程表的时间"],"Time before an issue starts implementation":["开始进行编码前的时间"],"Time between merge request creation and merge/close":["从创建合并请求到被合并或关闭的时间"],"Time until first merge request":["创建第一个合并请求之前的时间"],"Time|hr":["小时"],"Time|min":["分钟"],"Time|s":["秒"],"Total Time":["总时间"],"Total test time for all commits/merges":["所有提交和合并的总测试时间"],"Want to see the data? Please ask an administrator for access.":["权限不足。如需查看相关数据,请向管理员申请权限。"],"We don't have enough data to show this stage.":["该阶段的数据不足,无法显示。"],"You need permission.":["您需要相关的权限。"],"day":["天"]}}};
\ No newline at end of file diff --git a/app/assets/javascripts/locale/zh_HK/app.js b/app/assets/javascripts/locale/zh_HK/app.js new file mode 100644 index 00000000000..fd0bcd988c5 --- /dev/null +++ b/app/assets/javascripts/locale/zh_HK/app.js @@ -0,0 +1 @@ +var locales = locales || {}; locales['zh_HK'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-05-04 19:24-0500","PO-Revision-Date":"2017-05-04 19:24-0500","Last-Translator":"HuangTao <htve@outlook.com>, 2017","Language-Team":"Chinese (Hong Kong) (https://www.transifex.com/gitlab-zh/teams/75177/zh_HK/)","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Language":"zh_HK","Plural-Forms":"nplurals=1; plural=0;","lang":"zh_HK","domain":"app","plural_forms":"nplurals=1; plural=0;"},"ByAuthor|by":["作者:"],"Commit":["提交"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["週期分析概述了項目從想法到產品實現的各階段所需的時間。"],"CycleAnalyticsStage|Code":["編碼"],"CycleAnalyticsStage|Issue":["議題"],"CycleAnalyticsStage|Plan":["計劃"],"CycleAnalyticsStage|Production":["生產"],"CycleAnalyticsStage|Review":["評審"],"CycleAnalyticsStage|Staging":["預發布"],"CycleAnalyticsStage|Test":["測試"],"Deploy":["部署"],"FirstPushedBy|First":["首次推送"],"FirstPushedBy|pushed by":["推送者:"],"From issue creation until deploy to production":["從創建議題到部署到生產環境"],"From merge request merge until deploy to production":["從合併請求的合併到部署至生產環境"],"Introducing Cycle Analytics":["週期分析簡介"],"Last %d day":["最後 %d 天"],"Limited to showing %d event at most":["最多顯示 %d 個事件"],"Median":["中位數"],"New Issue":["新議題"],"Not available":["不可用"],"Not enough data":["數據不足"],"OpenedNDaysAgo|Opened":["開始於"],"Pipeline Health":["流水線健康指標"],"ProjectLifecycle|Stage":["項目生命週期"],"Read more":["了解更多"],"Related Commits":["相關的提交"],"Related Deployed Jobs":["相關的部署作業"],"Related Issues":["相關的議題"],"Related Jobs":["相關的作業"],"Related Merge Requests":["相關的合併請求"],"Related Merged Requests":["相關已合併的合並請求"],"Showing %d event":["顯示 %d 個事件"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["編碼階段概述了從第一次提交到創建合併請求的時間。創建第壹個合並請求後,數據將自動添加到此處。"],"The collection of events added to the data gathered for that stage.":["與該階段相關的事件。"],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["議題階段概述了從創建議題到將議題設置裏程碑或將議題添加到議題看板的時間。創建一個議題後,數據將自動添加到此處。"],"The phase of the development lifecycle.":["項目生命週期中的各個階段。"],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["計劃階段概述了從議題添加到日程後到推送首次提交的時間。當首次推送提交後,數據將自動添加到此處。"],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["生產階段概述了從創建議題到將代碼部署到生產環境的時間。當完成完整的想法到部署生產,數據將自動添加到此處。"],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["評審階段概述了從創建合並請求到合併的時間。當創建第壹個合並請求後,數據將自動添加到此處。"],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["預發布階段概述了合並請求的合併到部署代碼到生產環境的總時間。當首次部署到生產環境後,數據將自動添加到此處。"],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["測試階段概述了GitLab CI為相關合併請求運行每個流水線所需的時間。當第壹個流水線運行完成後,數據將自動添加到此處。"],"The time taken by each data entry gathered by that stage.":["該階段每條數據所花的時間"],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (5 + 7)/ 2 = 6。"],"Time before an issue gets scheduled":["議題被列入日程表的時間"],"Time before an issue starts implementation":["開始進行編碼前的時間"],"Time between merge request creation and merge/close":["從創建合併請求到被合並或關閉的時間"],"Time until first merge request":["創建第壹個合併請求之前的時間"],"Time|hr":["小時"],"Time|min":["分鐘"],"Time|s":["秒"],"Total Time":["總時間"],"Total test time for all commits/merges":["所有提交和合併的總測試時間"],"Want to see the data? Please ask an administrator for access.":["權限不足。如需查看相關數據,請向管理員申請權限。"],"We don't have enough data to show this stage.":["該階段的數據不足,無法顯示。"],"You need permission.":["您需要相關的權限。"],"day":["天"]}}};
\ No newline at end of file diff --git a/app/assets/javascripts/locale/zh_TW/app.js b/app/assets/javascripts/locale/zh_TW/app.js new file mode 100644 index 00000000000..79904d17bf6 --- /dev/null +++ b/app/assets/javascripts/locale/zh_TW/app.js @@ -0,0 +1 @@ +var locales = locales || {}; locales['zh_TW'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-05-04 19:24-0500","PO-Revision-Date":"2017-05-04 19:24-0500","Last-Translator":"HuangTao <htve@outlook.com>, 2017","Language-Team":"Chinese (Taiwan) (https://www.transifex.com/gitlab-zh/teams/75177/zh_TW/)","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Language":"zh_TW","Plural-Forms":"nplurals=1; plural=0;","lang":"zh_TW","domain":"app","plural_forms":"nplurals=1; plural=0;"},"ByAuthor|by":["作者:"],"Commit":["送交"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["週期分析概述了你的專案從想法到產品實現,各階段所需的時間。"],"CycleAnalyticsStage|Code":["程式開發"],"CycleAnalyticsStage|Issue":["議題"],"CycleAnalyticsStage|Plan":["計劃"],"CycleAnalyticsStage|Production":["上線"],"CycleAnalyticsStage|Review":["複閱"],"CycleAnalyticsStage|Staging":["預備"],"CycleAnalyticsStage|Test":["測試"],"Deploy":["部署"],"FirstPushedBy|First":["首次推送"],"FirstPushedBy|pushed by":["推送者:"],"From issue creation until deploy to production":["從議題建立至線上部署"],"From merge request merge until deploy to production":["從請求被合併後至線上部署"],"Introducing Cycle Analytics":["週期分析簡介"],"Last %d day":["最後 %d 天"],"Limited to showing %d event at most":["最多顯示 %d 個事件"],"Median":["中位數"],"New Issue":["新議題"],"Not available":["無法使用"],"Not enough data":["資料不足"],"OpenedNDaysAgo|Opened":["開始於"],"Pipeline Health":["流水線健康指標"],"ProjectLifecycle|Stage":["專案生命週期"],"Read more":["了解更多"],"Related Commits":["相關的送交"],"Related Deployed Jobs":["相關的部署作業"],"Related Issues":["相關的議題"],"Related Jobs":["相關的作業"],"Related Merge Requests":["相關的合併請求"],"Related Merged Requests":["相關已合併的請求"],"Showing %d event":["顯示 %d 個事件"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["程式開發階段顯示從第一次送交到建立合併請求的時間。建立第一個合併請求後,資料將自動填入。"],"The collection of events added to the data gathered for that stage.":["與該階段相關的事件。"],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["議題階段顯示從議題建立到設置里程碑、或將該議題加至議題看板的時間。建立第一個議題後,資料將自動填入。"],"The phase of the development lifecycle.":["專案開發生命週期的各個階段。"],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["計劃階段顯示從議題添加到日程後至推送第一個送交的時間。當第一次推送送交後,資料將自動填入。"],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["上線階段顯示從建立一個議題到部署程式至線上的總時間。當完成從想法到產品實現的循環後,資料將自動填入。"],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["複閱階段顯示從合併請求建立後至被合併的時間。當建立第一個合併請求後,資料將自動填入。"],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["預備階段顯示從合併請求被合併後至部署上線的時間。當第一次部署上線後,資料將自動填入。"],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["測試階段顯示相關合併請求的流水線所花的時間。當第一個流水線運作完畢後,資料將自動填入。"],"The time taken by each data entry gathered by that stage.":["每筆該階段相關資料所花的時間。"],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (5 + 7)/ 2 = 6。"],"Time before an issue gets scheduled":["議題被列入日程表的時間"],"Time before an issue starts implementation":["議題等待開始實作的時間"],"Time between merge request creation and merge/close":["合併請求被合併或是關閉的時間"],"Time until first merge request":["第一個合併請求被建立前的時間"],"Time|hr":["小時"],"Time|min":["分鐘"],"Time|s":["秒"],"Total Time":["總時間"],"Total test time for all commits/merges":["所有送交和合併的總測試時間"],"Want to see the data? Please ask an administrator for access.":["權限不足。如需查看相關資料,請向管理員申請權限。"],"We don't have enough data to show this stage.":["因該階段的資料不足而無法顯示相關資訊"],"You need permission.":["您需要相關的權限。"],"day":["天"]}}};
\ No newline at end of file diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 1ac82b7e291..fe367d0c42a 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -104,12 +104,11 @@ import './group_label_subscription'; import './groups_select'; import './header'; import './importer_status'; -import './issuable'; +import './issuable_index'; import './issuable_context'; import './issuable_form'; import './issue'; import './issue_status_select'; -import './issues_bulk_assignment'; import './label_manager'; import './labels'; import './labels_select'; diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 0ca7cabfc5a..929965de5c1 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -16,6 +16,7 @@ import autosize from 'vendor/autosize'; import Dropzone from 'dropzone'; import 'vendor/jquery.caret'; // required by jquery.atwho import 'vendor/jquery.atwho'; +import AjaxCache from '~/lib/utils/ajax_cache'; import CommentTypeToggle from './comment_type_toggle'; import './autosave'; import './dropzone_input'; @@ -66,7 +67,6 @@ const normalizeNewlines = function(str) { this.notesCountBadge || (this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge')); this.basePollingInterval = 15000; this.maxPollingSteps = 4; - this.flashErrors = []; this.cleanBinding(); this.addBinding(); @@ -325,6 +325,9 @@ const normalizeNewlines = function(str) { if (Notes.isNewNote(noteEntity, this.note_ids)) { this.note_ids.push(noteEntity.id); + if ($notesList.length) { + $notesList.find('.system-note.being-posted').remove(); + } const $newNote = Notes.animateAppendNote(noteEntity.html, $notesList); this.setupNewNote($newNote); @@ -1118,12 +1121,14 @@ const normalizeNewlines = function(str) { }; Notes.prototype.addFlash = function(...flashParams) { - this.flashErrors.push(new Flash(...flashParams)); + this.flashInstance = new Flash(...flashParams); }; Notes.prototype.clearFlash = function() { - this.flashErrors.forEach(flash => flash.flashContainer.remove()); - this.flashErrors = []; + if (this.flashInstance && this.flashInstance.flashContainer) { + this.flashInstance.flashContainer.hide(); + this.flashInstance = null; + } }; Notes.prototype.cleanForm = function($form) { @@ -1187,7 +1192,7 @@ const normalizeNewlines = function(str) { Notes.prototype.getFormData = function($form) { return { formData: $form.serialize(), - formContent: $form.find('.js-note-text').val(), + formContent: _.escape($form.find('.js-note-text').val()), formAction: $form.attr('action'), }; }; @@ -1207,19 +1212,46 @@ const normalizeNewlines = function(str) { }; /** + * Gets appropriate description from slash commands found in provided `formContent` + */ + Notes.prototype.getSlashCommandDescription = function (formContent, availableSlashCommands = []) { + let tempFormContent; + + // Identify executed slash commands from `formContent` + const executedCommands = availableSlashCommands.filter((command, index) => { + const commandRegex = new RegExp(`/${command.name}`); + return commandRegex.test(formContent); + }); + + if (executedCommands && executedCommands.length) { + if (executedCommands.length > 1) { + tempFormContent = 'Applying multiple commands'; + } else { + const commandDescription = executedCommands[0].description.toLowerCase(); + tempFormContent = `Applying command to ${commandDescription}`; + } + } else { + tempFormContent = 'Applying command'; + } + + return tempFormContent; + }; + + /** * Create placeholder note DOM element populated with comment body * that we will show while comment is being posted. * Once comment is _actually_ posted on server, we will have final element * in response that we will show in place of this temporary element. */ - Notes.prototype.createPlaceholderNote = function({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname }) { + Notes.prototype.createPlaceholderNote = function ({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname, currentUserAvatar }) { const discussionClass = isDiscussionNote ? 'discussion' : ''; - const escapedFormContent = _.escape(formContent); const $tempNote = $( `<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry"> <div class="timeline-entry-inner"> <div class="timeline-icon"> - <a href="/${currentUsername}"><span class="avatar dummy-avatar"></span></a> + <a href="/${currentUsername}"> + <img class="avatar s40" src="${currentUserAvatar}"> + </a> </div> <div class="timeline-content ${discussionClass}"> <div class="note-header"> @@ -1232,7 +1264,7 @@ const normalizeNewlines = function(str) { </div> <div class="note-body"> <div class="note-text"> - <p>${escapedFormContent}</p> + <p>${formContent}</p> </div> </div> </div> @@ -1244,6 +1276,23 @@ const normalizeNewlines = function(str) { }; /** + * Create Placeholder System Note DOM element populated with slash command description + */ + Notes.prototype.createPlaceholderSystemNote = function ({ formContent, uniqueId }) { + const $tempNote = $( + `<li id="${uniqueId}" class="note system-note timeline-entry being-posted fade-in-half"> + <div class="timeline-entry-inner"> + <div class="timeline-content"> + <i>${formContent}</i> + </div> + </div> + </li>` + ); + + return $tempNote; + }; + + /** * This method does following tasks step-by-step whenever a new comment * is submitted by user (both main thread comments as well as discussion comments). * @@ -1274,7 +1323,9 @@ const normalizeNewlines = function(str) { const isDiscussionForm = $form.hasClass('js-discussion-note-form'); const isDiscussionResolve = $submitBtn.hasClass('js-comment-resolve-button'); const { formData, formContent, formAction } = this.getFormData($form); - const uniqueId = _.uniqueId('tempNote_'); + let noteUniqueId; + let systemNoteUniqueId; + let hasSlashCommands = false; let $notesContainer; let tempFormContent; @@ -1295,16 +1346,28 @@ const normalizeNewlines = function(str) { tempFormContent = formContent; if (this.hasSlashCommands(formContent)) { tempFormContent = this.stripSlashCommands(formContent); + hasSlashCommands = true; } + // Show placeholder note if (tempFormContent) { - // Show placeholder note + noteUniqueId = _.uniqueId('tempNote_'); $notesContainer.append(this.createPlaceholderNote({ formContent: tempFormContent, - uniqueId, + uniqueId: noteUniqueId, isDiscussionNote, currentUsername: gon.current_username, currentUserFullname: gon.current_user_fullname, + currentUserAvatar: gon.current_user_avatar_url, + })); + } + + // Show placeholder system note + if (hasSlashCommands) { + systemNoteUniqueId = _.uniqueId('tempSystemNote_'); + $notesContainer.append(this.createPlaceholderSystemNote({ + formContent: this.getSlashCommandDescription(formContent, AjaxCache.get(gl.GfmAutoComplete.dataSources.commands)), + uniqueId: systemNoteUniqueId, })); } @@ -1322,7 +1385,13 @@ const normalizeNewlines = function(str) { gl.utils.ajaxPost(formAction, formData) .then((note) => { // Submission successful! remove placeholder - $notesContainer.find(`#${uniqueId}`).remove(); + $notesContainer.find(`#${noteUniqueId}`).remove(); + + // Reset cached commands list when command is applied + if (hasSlashCommands) { + $form.find('textarea.js-note-text').trigger('clear-commands-cache.atwho'); + } + // Clear previous form errors this.clearFlashWrapper(); @@ -1359,7 +1428,11 @@ const normalizeNewlines = function(str) { $form.trigger('ajax:success', [note]); }).fail(() => { // Submission failed, remove placeholder note and show Flash error message - $notesContainer.find(`#${uniqueId}`).remove(); + $notesContainer.find(`#${noteUniqueId}`).remove(); + + if (hasSlashCommands) { + $notesContainer.find(`#${systemNoteUniqueId}`).remove(); + } // Show form again on UI on failure if (isDiscussionForm && $notesContainer.length) { diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js index 0ef20af9260..01110420cca 100644 --- a/app/assets/javascripts/pager.js +++ b/app/assets/javascripts/pager.js @@ -6,11 +6,12 @@ import '~/lib/utils/url_utility'; const ENDLESS_SCROLL_FIRE_DELAY_MS = 1000; const Pager = { - init(limit = 0, preload = false, disable = false, callback = $.noop) { + init(limit = 0, preload = false, disable = false, prepareData = $.noop, callback = $.noop) { this.url = $('.content_list').data('href') || gl.utils.removeParams(['limit', 'offset']); this.limit = limit; this.offset = parseInt(gl.utils.getParameterByName('offset'), 10) || this.limit; this.disable = disable; + this.prepareData = prepareData; this.callback = callback; this.loading = $('.loading').first(); if (preload) { @@ -29,7 +30,7 @@ import '~/lib/utils/url_utility'; dataType: 'json', error: () => this.loading.hide(), success: (data) => { - this.append(data.count, data.html); + this.append(data.count, this.prepareData(data.html)); this.callback(); // keep loading until we've filled the viewport height diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue new file mode 100644 index 00000000000..4f6c5c177cf --- /dev/null +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -0,0 +1,97 @@ +<script> +import ciHeader from '../../vue_shared/components/header_ci_component.vue'; +import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + +export default { + name: 'PipelineHeaderSection', + props: { + pipeline: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + required: true, + }, + }, + components: { + ciHeader, + loadingIcon, + }, + + data() { + return { + actions: this.getActions(), + }; + }, + + computed: { + status() { + return this.pipeline.details && this.pipeline.details.status; + }, + shouldRenderContent() { + return !this.isLoading && Object.keys(this.pipeline).length; + }, + }, + + methods: { + postAction(action) { + const index = this.actions.indexOf(action); + + this.$set(this.actions[index], 'isLoading', true); + + eventHub.$emit('headerPostAction', action); + }, + + getActions() { + const actions = []; + + if (this.pipeline.retry_path) { + actions.push({ + label: 'Retry', + path: this.pipeline.retry_path, + cssClass: 'js-retry-button btn btn-inverted-secondary', + type: 'button', + isLoading: false, + }); + } + + if (this.pipeline.cancel_path) { + actions.push({ + label: 'Cancel running', + path: this.pipeline.cancel_path, + cssClass: 'js-btn-cancel-pipeline btn btn-danger', + type: 'button', + isLoading: false, + }); + } + + return actions; + }, + }, + + watch: { + pipeline() { + this.actions = this.getActions(); + }, + }, +}; +</script> +<template> + <div class="pipeline-header-container"> + <ci-header + v-if="shouldRenderContent" + :status="status" + item-name="Pipeline" + :item-id="pipeline.id" + :time="pipeline.created_at" + :user="pipeline.user" + :actions="actions" + @actionClicked="postAction" + /> + <loading-icon + v-else + size="2"/> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue index b8457fae967..4781a8ff1da 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue @@ -33,7 +33,7 @@ export default { <user-avatar-link v-if="user" class="js-pipeline-url-user" - :link-href="pipeline.user.web_url" + :link-href="pipeline.user.path" :img-src="pipeline.user.avatar_url" :tooltip-text="pipeline.user.name" /> diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue index 7fc19fce1ff..c05c76c9a64 100644 --- a/app/assets/javascripts/pipelines/components/stage.vue +++ b/app/assets/javascripts/pipelines/components/stage.vue @@ -16,6 +16,7 @@ /* global Flash */ import { borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons'; import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import tooltipMixin from '../../vue_shared/mixins/tooltip'; export default { props: { @@ -31,6 +32,10 @@ export default { }, }, + mixins: [ + tooltipMixin, + ], + data() { return { isLoading: false, @@ -127,9 +132,10 @@ export default { <template> <div class="dropdown"> <button + ref="tooltip" :class="triggerButtonClass" @click="onClickStage" - class="mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button" + class="mini-pipeline-graph-dropdown-toggle js-builds-dropdown-button" :title="stage.title" data-placement="top" data-toggle="dropdown" diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 5aab25e0348..bfc416da50b 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -1,6 +1,10 @@ +/* global Flash */ + import Vue from 'vue'; import PipelinesMediator from './pipeline_details_mediatior'; import pipelineGraph from './components/graph/graph_component.vue'; +import pipelineHeader from './components/header_component.vue'; +import eventHub from './event_hub'; document.addEventListener('DOMContentLoaded', () => { const dataset = document.querySelector('.js-pipeline-details-vue').dataset; @@ -9,7 +13,8 @@ document.addEventListener('DOMContentLoaded', () => { mediator.fetchPipeline(); - const pipelineGraphApp = new Vue({ + // eslint-disable-next-line + new Vue({ el: '#js-pipeline-graph-vue', data() { return { @@ -29,5 +34,37 @@ document.addEventListener('DOMContentLoaded', () => { }, }); - return pipelineGraphApp; + // eslint-disable-next-line + new Vue({ + el: '#js-pipeline-header-vue', + data() { + return { + mediator, + }; + }, + components: { + pipelineHeader, + }, + created() { + eventHub.$on('headerPostAction', this.postAction); + }, + beforeDestroy() { + eventHub.$off('headerPostAction', this.postAction); + }, + methods: { + postAction(action) { + this.mediator.service.postAction(action.path) + .then(() => this.mediator.refreshPipeline()) + .catch(() => new Flash('An error occurred while making the request.')); + }, + }, + render(createElement) { + return createElement('pipeline-header', { + props: { + isLoading: this.mediator.state.isLoading, + pipeline: this.mediator.store.state.pipeline, + }, + }); + }, + }); }); diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediatior.js b/app/assets/javascripts/pipelines/pipeline_details_mediatior.js index b9a6d5ca5fc..82537ea06f5 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_mediatior.js +++ b/app/assets/javascripts/pipelines/pipeline_details_mediatior.js @@ -26,6 +26,8 @@ export default class pipelinesMediator { if (!Visibility.hidden()) { this.state.isLoading = true; this.poll.makeRequest(); + } else { + this.refreshPipeline(); } Visibility.change(() => { @@ -48,4 +50,10 @@ export default class pipelinesMediator { this.state.isLoading = false; return new Flash('An error occurred while fetching the pipeline.'); } + + refreshPipeline() { + this.service.getPipeline() + .then(response => this.successCallback(response)) + .catch(() => this.errorCallback()); + } } diff --git a/app/assets/javascripts/pipelines/pipelines.js b/app/assets/javascripts/pipelines/pipelines.js index ba06d79102f..23b967b4b32 100644 --- a/app/assets/javascripts/pipelines/pipelines.js +++ b/app/assets/javascripts/pipelines/pipelines.js @@ -169,7 +169,7 @@ export default { eventHub.$on('refreshPipelines', this.fetchPipelines); }, - beforeDestroyed() { + beforeDestroy() { eventHub.$off('refreshPipelines'); }, diff --git a/app/assets/javascripts/pipelines/services/pipeline_service.js b/app/assets/javascripts/pipelines/services/pipeline_service.js index f1cc60c1ee0..3e0c52c7726 100644 --- a/app/assets/javascripts/pipelines/services/pipeline_service.js +++ b/app/assets/javascripts/pipelines/services/pipeline_service.js @@ -11,4 +11,9 @@ export default class PipelineService { getPipeline() { return this.pipeline.get(); } + + // eslint-disable-next-line + postAction(endpoint) { + return Vue.http.post(`${endpoint}.json`); + } } diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js index b21f84b4545..e2285494e62 100644 --- a/app/assets/javascripts/pipelines/services/pipelines_service.js +++ b/app/assets/javascripts/pipelines/services/pipelines_service.js @@ -33,8 +33,6 @@ export default class PipelinesService { /** * Post request for all pipelines actions. - * Endpoint content type needs to be: - * `Content-Type:application/x-www-form-urlencoded` * * @param {String} endpoint * @return {Promise} diff --git a/app/assets/javascripts/project_new.js b/app/assets/javascripts/project_new.js index 04b381fe0e0..c0f757269cb 100644 --- a/app/assets/javascripts/project_new.js +++ b/app/assets/javascripts/project_new.js @@ -1,11 +1,17 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-unused-vars, one-var, no-underscore-dangle, prefer-template, no-else-return, prefer-arrow-callback, max-len */ +function highlightChanges($elm) { + $elm.addClass('highlight-changes'); + setTimeout(() => $elm.removeClass('highlight-changes'), 10); +} + (function() { this.ProjectNew = (function() { function ProjectNew() { this.toggleSettings = this.toggleSettings.bind(this); this.$selects = $('.features select'); this.$repoSelects = this.$selects.filter('.js-repo-select'); + this.$projectSelects = this.$selects.not('.js-repo-select'); $('.project-edit-container').on('ajax:before', (function(_this) { return function() { @@ -26,6 +32,42 @@ if (!visibilityContainer) return; const visibilitySelect = new gl.VisibilitySelect(visibilityContainer); visibilitySelect.init(); + + const $visibilitySelect = $(visibilityContainer).find('select'); + let projectVisibility = $visibilitySelect.val(); + const PROJECT_VISIBILITY_PRIVATE = '0'; + + $visibilitySelect.on('change', () => { + const newProjectVisibility = $visibilitySelect.val(); + + if (projectVisibility !== newProjectVisibility) { + this.$projectSelects.each((idx, select) => { + const $select = $(select); + const $options = $select.find('option'); + const values = $.map($options, e => e.value); + + // if switched to "private", limit visibility options + if (newProjectVisibility === PROJECT_VISIBILITY_PRIVATE) { + if ($select.val() !== values[0] && $select.val() !== values[1]) { + $select.val(values[1]).trigger('change'); + highlightChanges($select); + } + $options.slice(2).disable(); + } + + // if switched from "private", increase visibility for non-disabled options + if (projectVisibility === PROJECT_VISIBILITY_PRIVATE) { + $options.enable(); + if ($select.val() !== values[0] && $select.val() !== values[values.length - 1]) { + $select.val(values[values.length - 1]).trigger('change'); + highlightChanges($select); + } + } + }); + + projectVisibility = newProjectVisibility; + } + }); }; ProjectNew.prototype.toggleSettings = function() { @@ -56,8 +98,10 @@ ProjectNew.prototype.toggleRepoVisibility = function () { var $repoAccessLevel = $('.js-repo-access-level select'); + var $lfsEnabledOption = $('.js-lfs-enabled select'); var containerRegistry = document.querySelectorAll('.js-container-registry')[0]; var containerRegistryCheckbox = document.getElementById('project_container_registry_enabled'); + var prevSelectedVal = parseInt($repoAccessLevel.val(), 10); this.$repoSelects.find("option[value='" + $repoAccessLevel.val() + "']") .nextAll() @@ -71,29 +115,40 @@ var $this = $(this); var repoSelectVal = parseInt($this.val(), 10); - $this.find('option').show(); + $this.find('option').enable(); - if (selectedVal < repoSelectVal) { - $this.val(selectedVal); + if (selectedVal < repoSelectVal || repoSelectVal === prevSelectedVal) { + $this.val(selectedVal).trigger('change'); + highlightChanges($this); } - $this.find("option[value='" + selectedVal + "']").nextAll().hide(); + $this.find("option[value='" + selectedVal + "']").nextAll().disable(); }); if (selectedVal) { this.$repoSelects.removeClass('disabled'); + if ($lfsEnabledOption.length) { + $lfsEnabledOption.removeClass('disabled'); + highlightChanges($lfsEnabledOption); + } if (containerRegistry) { containerRegistry.style.display = ''; } } else { this.$repoSelects.addClass('disabled'); + if ($lfsEnabledOption.length) { + $lfsEnabledOption.val('false').addClass('disabled'); + highlightChanges($lfsEnabledOption); + } if (containerRegistry) { containerRegistry.style.display = 'none'; containerRegistryCheckbox.checked = false; } } + + prevSelectedVal = selectedVal; }.bind(this)); }; diff --git a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js index 068e9698e1d..9d045886262 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js +++ b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js @@ -10,7 +10,7 @@ export default class ProtectedTagDropdown { this.$dropdown = options.$dropdown; this.$dropdownContainer = this.$dropdown.parent(); this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer'); - this.$protectedTag = this.$dropdownContainer.find('.create-new-protected-tag'); + this.$protectedTag = this.$dropdownContainer.find('.js-create-new-protected-tag'); this.buildDropdown(); this.bindEvents(); @@ -73,7 +73,7 @@ export default class ProtectedTagDropdown { }; this.$dropdownContainer - .find('.create-new-protected-tag code') + .find('.js-create-new-protected-tag code') .text(tagName); } diff --git a/app/assets/javascripts/settings_panels.js b/app/assets/javascripts/settings_panels.js new file mode 100644 index 00000000000..e67f449e1a2 --- /dev/null +++ b/app/assets/javascripts/settings_panels.js @@ -0,0 +1,27 @@ +function expandSection($section) { + $section.find('.js-settings-toggle').text('Close'); + $section.find('.settings-content').addClass('expanded').off('scroll').scrollTop(0); +} + +function closeSection($section) { + $section.find('.js-settings-toggle').text('Expand'); + $section.find('.settings-content').removeClass('expanded').on('scroll', () => expandSection($section)); +} + +function toggleSection($section) { + const $content = $section.find('.settings-content'); + $content.removeClass('no-animate'); + if ($content.hasClass('expanded')) { + closeSection($section); + } else { + expandSection($section); + } +} + +export default function initSettingsPanels() { + $('.settings').each((i, elm) => { + const $section = $(elm); + $section.on('click', '.js-settings-toggle', () => toggleSection($section)); + $section.find('.settings-content:not(.expanded)').on('scroll', () => expandSection($section)); + }); +} diff --git a/app/assets/javascripts/user_callout.js b/app/assets/javascripts/user_callout.js index b9d57cbcad4..ff2208baeab 100644 --- a/app/assets/javascripts/user_callout.js +++ b/app/assets/javascripts/user_callout.js @@ -1,11 +1,10 @@ import Cookies from 'js-cookie'; -const USER_CALLOUT_COOKIE = 'user_callout_dismissed'; - export default class UserCallout { - constructor() { - this.isCalloutDismissed = Cookies.get(USER_CALLOUT_COOKIE); - this.userCalloutBody = $('.user-callout'); + constructor(className = 'user-callout') { + this.userCalloutBody = $(`.${className}`); + this.cookieName = this.userCalloutBody.data('uid'); + this.isCalloutDismissed = Cookies.get(this.cookieName); this.init(); } @@ -18,7 +17,7 @@ export default class UserCallout { dismissCallout(e) { const $currentTarget = $(e.currentTarget); - Cookies.set(USER_CALLOUT_COOKIE, 'true', { expires: 365 }); + Cookies.set(this.cookieName, 'true', { expires: 365 }); if ($currentTarget.hasClass('close')) { this.userCalloutBody.remove(); diff --git a/app/assets/javascripts/vue_shared/components/commit.js b/app/assets/javascripts/vue_shared/components/commit.js index 23bc5fbc034..ff5ae28e062 100644 --- a/app/assets/javascripts/vue_shared/components/commit.js +++ b/app/assets/javascripts/vue_shared/components/commit.js @@ -91,7 +91,7 @@ export default { hasAuthor() { return this.author && this.author.avatar_url && - this.author.web_url && + this.author.path && this.author.username; }, @@ -135,12 +135,12 @@ export default { {{shortSha}} </a> - <p class="commit-title"> - <span v-if="title"> + <div class="commit-title flex-truncate-parent"> + <span v-if="title" class="flex-truncate-child"> <user-avatar-link v-if="hasAuthor" class="avatar-image-container" - :link-href="author.web_url" + :link-href="author.path" :img-src="author.avatar_url" :img-alt="userImageAltDescription" :tooltip-text="author.username" @@ -153,7 +153,7 @@ export default { <span v-else> Cant find HEAD commit for this branch </span> - </p> + </div> </div> `, }; diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index fd0dcd716d6..fe6d6a792e7 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -1,8 +1,9 @@ <script> import ciIconBadge from './ci_badge_link.vue'; +import loadingIcon from './loading_icon.vue'; import timeagoTooltip from './time_ago_tooltip.vue'; import tooltipMixin from '../mixins/tooltip'; -import userAvatarLink from './user_avatar/user_avatar_link.vue'; +import userAvatarImage from './user_avatar/user_avatar_image.vue'; /** * Renders header component for job and pipeline page based on UI mockups @@ -31,7 +32,8 @@ export default { }, user: { type: Object, - required: true, + required: false, + default: () => ({}), }, actions: { type: Array, @@ -46,8 +48,9 @@ export default { components: { ciIconBadge, + loadingIcon, timeagoTooltip, - userAvatarLink, + userAvatarImage, }, computed: { @@ -58,13 +61,13 @@ export default { methods: { onClickAction(action) { - this.$emit('postAction', action); + this.$emit('actionClicked', action); }, }, }; </script> <template> - <header class="page-content-header top-area"> + <header class="page-content-header"> <section class="header-main-content"> <ci-icon-badge :status="status" /> @@ -79,21 +82,23 @@ export default { by - <user-avatar-link - :link-href="user.web_url" - :img-src="user.avatar_url" - :img-alt="userAvatarAltText" - :tooltip-text="user.name" - :img-size="24" - /> - - <a - :href="user.web_url" - :title="user.email" - class="js-user-link commit-committer-link" - ref="tooltip"> - {{user.name}} - </a> + <template v-if="user"> + <a + :href="user.path" + :title="user.email" + class="js-user-link commit-committer-link" + ref="tooltip"> + + <user-avatar-image + :img-src="user.avatar_url" + :img-alt="userAvatarAltText" + :tooltip-text="user.name" + :img-size="24" + /> + + {{user.name}} + </a> + </template> </section> <section @@ -111,11 +116,17 @@ export default { <button v-else="action.type === 'button'" @click="onClickAction(action)" + :disabled="action.isLoading" :class="action.cssClass" type="button"> {{action.label}} - </button> + <i + v-show="action.isLoading" + class="fa fa-spin fa-spinner" + aria-hidden="true"> + </i> + </button> </template> </section> </header> diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js index 3283a6bcacc..f60f8eeb43d 100644 --- a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js +++ b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js @@ -83,7 +83,7 @@ export default { } else { commitAuthorInformation = { avatar_url: this.pipeline.commit.author_gravatar_url, - web_url: `mailto:${this.pipeline.commit.author_email}`, + path: `mailto:${this.pipeline.commit.author_email}`, username: this.pipeline.commit.author_name, }; } diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue index b8db6afda12..cd6f8c7aee4 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue @@ -60,6 +60,12 @@ export default { avatarSizeClass() { return `s${this.size}`; }, + // API response sends null when gravatar is disabled and + // we provide an empty string when we use it inside user avatar link. + // In both cases we should render the defaultAvatarUrl + imageSource() { + return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; + }, }, }; </script> @@ -68,7 +74,7 @@ export default { <img class="avatar" :class="[avatarSizeClass, cssClasses]" - :src="imgSrc" + :src="imageSource" :width="size" :height="size" :alt="imgAlt" diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index b8ba77f4513..9dc9f9a9068 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -49,3 +49,4 @@ @import "framework/icons.scss"; @import "framework/snippets.scss"; @import "framework/memory_graph.scss"; +@import "framework/responsive-tables.scss"; diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 57387b913dc..00c981f64c5 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -445,3 +445,9 @@ table { word-wrap: break-word; } } + +.disabled-content { + pointer-events: none; + opacity: .5; +} + diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 5ab48b6c874..9c70f1a1ef2 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -201,6 +201,11 @@ width: 100%; } + &.dropdown-open-left { + right: 0; + left: auto; + } + &.is-loading { .dropdown-content { display: none; diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 78f425057eb..d08df05fd6c 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -85,7 +85,7 @@ } /** - * Blame file + * Annotate file */ &.blame { table { diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 90051ffe753..cfbaaaa04c7 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -22,12 +22,6 @@ } @media (min-width: $screen-sm-min) { - .issues_bulk_update { - .dropdown-menu-toggle { - width: 132px; - } - } - .filter-item:not(:last-child) { margin-right: 6px; } @@ -90,6 +84,7 @@ .filtered-search-term { display: -webkit-flex; display: flex; + flex-shrink: 0; margin-top: 5px; margin-bottom: 5px; @@ -147,15 +142,17 @@ } } } +} - .selected { - .name { - background-color: $filter-name-selected-color; - } +.filtered-search-token:hover, +.filtered-search-token .selected, +.filtered-search-term .selected { + .name { + background-color: $filter-name-selected-color; + } - .value-container { - background-color: $filter-value-selected-color; - } + .value-container { + background-color: $filter-value-selected-color; } } @@ -239,7 +236,7 @@ width: 35px; background-color: $white-light; border: none; - position: absolute; + position: static; right: 0; height: 100%; outline: none; @@ -375,12 +372,6 @@ padding: 0; } -@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { - .issue-bulk-update-dropdown-toggle { - width: 100px; - } -} - @media (max-width: $screen-xs-max) { .issues-details-filters { padding: 0 0 10px; diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index 25b4feca3c3..38d884bc7eb 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -16,6 +16,22 @@ @extend .alert; @extend .alert-danger; margin: 0; + + .flash-text, + .flash-action { + display: inline-block; + } + + a.flash-action { + margin-left: 5px; + text-decoration: none; + font-weight: normal; + border-bottom: 1px solid; + + &:hover { + border-color: transparent; + } + } } .flash-notice, diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss index 678af978edd..600a1f53b58 100644 --- a/app/assets/stylesheets/framework/mobile.scss +++ b/app/assets/stylesheets/framework/mobile.scss @@ -29,10 +29,6 @@ display: none; } - .issues-holder .issue-check { - display: none; - } - .rss-btn { display: none; } @@ -112,11 +108,6 @@ } } - .issue-edited-ago, - .note_edited_ago { - display: none; - } - aside:not(.right-sidebar) { display: none; } diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index 28b2a7cfacd..3787ef370b2 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -45,7 +45,8 @@ li { display: flex; - a { + a, + .btn-link { padding: $gl-btn-padding; padding-bottom: 11px; font-size: 14px; @@ -67,7 +68,29 @@ } } - &.active a { + .btn-link { + padding-top: 16px; + padding-left: 15px; + padding-right: 15px; + border-left: none; + border-right: none; + border-top: none; + border-radius: 0; + + &:hover, + &:active, + &:focus { + background-color: transparent; + } + + &:active { + outline: 0; + box-shadow: none; + } + } + + &.active a, + &.active .btn-link { border-bottom: 2px solid $link-underline-blue; color: $black; font-weight: 600; diff --git a/app/assets/stylesheets/framework/responsive-tables.scss b/app/assets/stylesheets/framework/responsive-tables.scss new file mode 100644 index 00000000000..a24483fa431 --- /dev/null +++ b/app/assets/stylesheets/framework/responsive-tables.scss @@ -0,0 +1,90 @@ +@mixin flex-max-width($max) { + flex: 0 0 #{$max + '%'}; + max-width: #{$max + '%'}; +} + +.gl-responsive-table-row { + margin-top: 10px; + border: 1px solid $border-color; + + @media (min-width: $screen-md-min) { + padding: 15px 0; + margin: 0; + display: flex; + align-items: center; + border: none; + border-bottom: 1px solid $white-normal; + } + + .table-section { + white-space: nowrap; + + .branch-commit { + max-width: 100%; + } + + $section-widths: 10 15 20 25 30 40; + @each $width in $section-widths { + &.section-#{$width} { + flex: 0 0 #{$width + '%'}; + + @media (min-width: $screen-md-min) { + max-width: #{$width + '%'}; + } + } + } + + &:not(.table-button-footer) { + @media (max-width: $screen-sm-max) { + display: flex; + align-self: stretch; + padding: 10px; + align-items: center; + height: 62px; + + &:not(:first-of-type) { + border-top: 1px solid $white-normal; + } + } + } + } +} + +.table-row-header { + font-size: 13px; + + @media (max-width: $screen-sm-max) { + display: none; + } +} + +.table-mobile-header { + color: $gl-text-color-secondary; + @include flex-max-width(40); + + @media (min-width: $screen-md-min) { + display: none; + } +} + +.table-mobile-content { + @media (max-width: $screen-sm-max) { + @include flex-max-width(60); + text-align: right; + } +} + +.flex-truncate-parent { + display: flex; +} + +.flex-truncate-child { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + @media (min-width: $screen-md-min) { + flex: 0 0 90%; + } +} diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 5b62d7fa3a7..d4421e3af74 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -33,7 +33,7 @@ padding-right: 0; @media (min-width: $screen-sm-min) { - &:not(.wiki-sidebar):not(.build-sidebar) .content-wrapper { + &:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper { padding-right: $gutter_collapsed_width; } @@ -56,7 +56,7 @@ z-index: 300; @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { - &:not(.wiki-sidebar):not(.build-sidebar) .content-wrapper { + &:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper { padding-right: $gutter_collapsed_width; } } @@ -88,3 +88,35 @@ min-height: 100%; } } + +@mixin maintain-sidebar-dimensions { + display: block; + width: $gutter-width; + padding: 10px 20px; +} + +.issues-bulk-update.right-sidebar { + @include maintain-sidebar-dimensions; + transition: right $sidebar-transition-duration; + right: -$gutter-width; + + &.right-sidebar-expanded { + @include maintain-sidebar-dimensions; + right: 0; + } + + &.right-sidebar-collapsed { + @include maintain-sidebar-dimensions; + right: -$gutter-width; + + .block { + padding: 16px 0; + width: 250px; + border-bottom: 1px solid $border-color; + } + } + + .issuable-sidebar { + padding: 0 3px; + } +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 975a4b40383..4114a050d9a 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -187,6 +187,7 @@ $divergence-graph-bar-bg: #ccc; $divergence-graph-separator-bg: #ccc; $general-hover-transition-duration: 100ms; $general-hover-transition-curve: linear; +$highlight-changes-color: rgb(235, 255, 232); /* @@ -569,3 +570,10 @@ $filter-value-selected-color: #d7d7d7; Animation Functions */ $dropdown-animation-timing: cubic-bezier(0.23, 1, 0.32, 1); + +/* +Convdev Index +*/ +$color-high-score: $green-400; +$color-average-score: $orange-400; +$color-low-score: $red-400; diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index ebe662136d5..740e383dbb5 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -96,9 +96,51 @@ @media (min-width: $screen-sm-min) { width: 400px; } + + &.is-expandable { + .board-header { + cursor: pointer; + } + } + + &.is-collapsed { + width: 50px; + + .board-header { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + } + + .board-title { + position: initial; + padding: 0; + border-bottom: 0; + + > span { + display: block; + transform: rotate(90deg) translate(25px, 0); + } + } + + .board-title-expandable-toggle { + position: absolute; + top: 50%; + left: 50%; + margin-left: -10px; + } + + .board-list-component, + .board-issue-count-holder { + display: none; + } + } } .board-inner { + position: relative; height: 100%; font-size: $issue-boards-font-size; background: $gray-light; @@ -175,21 +217,53 @@ } } +.slide-down-enter { + transform: translateY(-100%); +} + +.slide-down-enter-active { + transition: transform $fade-in-duration; + + + .board-list { + transform: translateY(-136px); + transition: none; + } +} + +.slide-down-enter-to { + + .board-list { + transform: translateY(0); + transition: transform $fade-in-duration ease; + } +} + +.slide-down-leave { + transform: translateY(0); +} + +.slide-down-leave-active { + transition: all $fade-in-duration; + transform: translateY(-136px); + + + .board-list { + transition: transform $fade-in-duration ease; + transform: translateY(-136px); + } +} + .board-list-component { height: calc(100% - 49px); + overflow: hidden; } .board-list { height: 100%; + width: 100%; margin-bottom: 0; padding: 5px; list-style: none; overflow-y: scroll; overflow-x: hidden; - - &.is-smaller { - height: calc(100% - 136px); - } } .board-list-loading { @@ -351,6 +425,7 @@ } .board-new-issue-form { + z-index: 1; margin: 5px; } diff --git a/app/assets/stylesheets/pages/convdev_index.scss b/app/assets/stylesheets/pages/convdev_index.scss new file mode 100644 index 00000000000..0413114c279 --- /dev/null +++ b/app/assets/stylesheets/pages/convdev_index.scss @@ -0,0 +1,255 @@ +$space-between-cards: 8px; + +.convdev-empty svg { + margin: 64px auto 32px; + max-width: 420px; +} + +.convdev-header { + margin-top: $gl-padding; + margin-bottom: $gl-padding; + padding: 0 4px; + display: flex; + align-items: center; + + .convdev-header-title { + font-size: 48px; + line-height: 1; + margin: 0; + } + + .convdev-header-subtitle { + font-size: 22px; + line-height: 1; + color: $gl-text-color-secondary; + margin-left: 8px; + font-weight: 500; + + a { + font-size: 18px; + color: $gl-text-color-secondary; + + &:hover { + color: $blue-500; + } + } + } +} + +.convdev-cards { + display: flex; + justify-content: center; + flex-wrap: wrap; +} + +.convdev-card-wrapper { + display: flex; + flex-direction: column; + align-items: stretch; + text-align: center; + width: 50%; + border-color: $border-color; + margin: 0 0 32px; + padding: $space-between-cards / 2; + position: relative; + + @media (min-width: $screen-xs-min) { + width: percentage(1 / 4); + } + + @media (min-width: $screen-sm-min) { + width: percentage(1 / 5); + } + + @media (min-width: $screen-md-min) { + width: percentage(1 / 6); + } + + @media (min-width: $screen-lg-min) { + width: percentage(1 / 10); + } +} + +.convdev-card { + border: solid 1px $border-color; + border-radius: 3px; + border-top-width: 3px; + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.convdev-card-low { + border-top-color: $color-low-score; + + .card-score-big { + background-color: $red-25; + } +} + +.convdev-card-average { + border-top-color: $color-average-score; + + .card-score-big { + background-color: $orange-25; + } +} + +.convdev-card-high { + border-top-color: $color-high-score; + + .card-score-big { + background-color: $green-25; + } +} + +.convdev-card-title { + margin: $gl-padding auto auto; + max-width: 100px; + + h3 { + font-size: 14px; + margin: 0 0 2px; + } + + .text-light { + font-size: 13px; + line-height: 1.25; + color: $gl-text-color-secondary; + } +} + +.card-scores { + display: flex; + justify-content: space-around; + align-items: center; + margin: $gl-padding $gl-btn-padding; + line-height: 1; +} + +.card-score { + color: $gl-text-color-secondary; + + .card-score-name { + font-size: 13px; + margin-top: 4px; + } +} + +.card-score-value { + font-size: 16px; + color: $gl-text-color; + font-weight: 500; +} + +.card-score-big { + border-top: 2px solid $border-color; + border-bottom: 1px solid $border-color; + font-size: 22px; + padding: 10px 0; + font-weight: 500; +} + +.card-buttons { + display: flex; + + > * { + font-size: 16px; + color: $gl-text-color-secondary; + padding: 10px; + flex-grow: 1; + + &:hover { + background-color: $border-color; + color: $gl-text-color; + } + + + * { + border-left: solid 1px $border-color; + } + } +} + +.convdev-steps { + margin-top: $gl-padding; + height: 1px; + min-width: 100%; + justify-content: space-around; + position: relative; + background: $border-color; +} + +.convdev-step { + $step-positions: 5% 10% 30% 42% 48% 55% 60% 70% 75% 90%; + @each $pos in $step-positions { + $i: index($step-positions, $pos); + + &:nth-child(#{$i}) { + left: $pos; + } + } + + position: absolute; + transform-origin: 75% 50%; + padding: 8px; + height: 50px; + width: 50px; + border-radius: 3px; + display: flex; + flex-direction: column; + align-items: center; + border: solid 1px $border-color; + background: $white-light; + transform: translate(-50%, -50%); + color: $gl-text-color-secondary; + fill: $gl-text-color-secondary; + box-shadow: 0 2px 4px $dropdown-shadow-color; + + &:hover { + padding: 8px 10px; + fill: currentColor; + z-index: 100; + height: auto; + width: auto; + + .convdev-step-title { + max-height: 2em; + opacity: 1; + transition: opacity 0.2s; + } + + svg { + transform: scale(1.5); + margin: $gl-btn-padding; + } + } + + svg { + transition: transform 0.1s; + width: 30px; + height: 30px; + min-height: 30px; + min-width: 30px; + } +} + +.convdev-step-title { + max-height: 0; + opacity: 0; + text-transform: uppercase; + margin: $gl-vert-padding 0 0; + text-align: center; + font-size: 12px; +} + +.convdev-high-score { + color: $color-high-score; +} + +.convdev-average-score { + color: $color-average-score; +} + +.convdev-low-score { + color: $color-low-score; +} diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index f269d53093d..a44a8bce879 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -11,34 +11,7 @@ } .environments-container { - .table-holder { - width: 100%; - - @media (max-width: $screen-sm-max) { - overflow: auto; - } - } - - .table.ci-table { - .environments-actions { - min-width: 300px; - } - - .environments-commit, - .environments-actions { - width: 20%; - } - - .environments-date { - width: 10%; - } - - .environments-name, - .environments-deploy, - .environments-build { - width: 15%; - } - + .ci-table { .deployment-column { > span { word-break: break-all; @@ -150,6 +123,55 @@ } } +.gl-responsive-table-row { + .environments-actions { + @media (min-width: $screen-md-min) { + text-align: right; + } + + @media (max-width: $screen-sm-max) { + background-color: $gray-normal; + align-self: stretch; + border-top: 1px solid $border-color; + + .environment-action-buttons { + padding: 10px 5px; + display: flex; + + .btn { + border-radius: 3px; + } + + > .btn-group, + .external-url, + .btn { + flex: 1; + flex-basis: 28px; + margin: 0 5px; + } + + .dropdown-new { + width: 100%; + } + } + } + } + + .branch-commit { + max-width: 100%; + } +} + +.folder-row { + padding: 15px 0; + border-bottom: 1px solid $white-normal; + + @media (max-width: $screen-sm-max) { + border-top: 1px solid $white-normal; + margin-top: 10px; + } +} + .prometheus-graph { text { fill: $gl-text-color; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index c2346f2f1c3..a321941e0c9 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -729,3 +729,34 @@ } } } + +.confidential-issue-warning { + background-color: $gl-gray; + border-radius: 3px; + padding: $gl-btn-padding $gl-padding; + margin-top: $gl-padding-top; + font-size: 14px; + color: $white-light; + + .fa { + margin-right: 8px; + } + + a { + color: $white-light; + text-decoration: underline; + } + + &.affix { + position: static; + width: initial; + + @media (min-width: $screen-sm-min) { + position: sticky; + position: -webkit-sticky; + top: 60px; + z-index: 200; + @include transition(all); + } + } +} diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 875e47cdff3..aa307414737 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -103,26 +103,6 @@ } } -.confidential-issue-warning { - background-color: $gray-normal; - border-radius: 3px; - padding: 3px 12px; - margin: auto; - margin-top: 0; - text-align: center; - font-size: 12px; - - @media (max-width: $screen-sm-max) { - // On smaller devices the warning becomes the fourth item in the list, - // rather than centering, and grows to span the full width of the - // comment area. - order: 4; - -webkit-order: 4; - margin: 6px auto; - width: 100%; - } -} - .discussion-form { padding: $gl-padding-top $gl-padding $gl-padding; background-color: $white-light; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index f956e3757bf..e622e5c3f4b 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -38,9 +38,12 @@ ul.notes { } .discussion { - overflow: hidden; display: block; position: relative; + + .diff-content { + overflow: visible; + } } > li { @@ -443,6 +446,53 @@ ul.notes { .note-action-button { margin-left: 8px; } + + .more-actions-toggle { + margin-left: 2px; + } +} + +.more-actions { + display: inline; + + .tooltip { + white-space: nowrap; + } +} + +.more-actions-toggle { + padding: 0; + outline: none; + + &:hover .icon, + &:focus .icon { + color: $blue-600; + } + + .icon { + padding: 0 6px; + } +} + +.more-actions-dropdown { + width: 180px; + min-width: 180px; + margin-top: $gl-btn-padding; + + li > a, + li > .btn { + color: $gl-text-color; + padding: $gl-btn-padding; + width: 100%; + text-align: left; + + &:hover, + &:focus { + color: $gl-text-color; + background-color: $blue-25; + border-radius: $border-radius-default; + } + } } .discussion-actions { diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index cf2e565dd2d..58b458cd837 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -984,3 +984,11 @@ width: 12px; } } + +.pipeline-header-container { + min-height: 55px; + + .text-center { + padding-top: 12px; + } +} diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index fe084eb9397..c207159f606 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -287,6 +287,7 @@ table.u2f-registrations { .user-callout { margin: 0 auto; + max-width: $screen-lg-min; .bordered-box { border: 1px solid $blue-300; @@ -295,14 +296,15 @@ table.u2f-registrations { position: relative; display: flex; justify-content: center; + align-items: center; } .landing { - margin-top: $gl-padding; - margin-bottom: $gl-padding; + padding: 32px; .close { position: absolute; + top: 20px; right: 20px; opacity: 1; @@ -330,11 +332,20 @@ table.u2f-registrations { height: 110px; vertical-align: top; } + + &.convdev { + margin: 0 0 0 30px; + + svg { + height: 127px; + } + } } .user-callout-copy { display: inline-block; vertical-align: top; + max-width: 570px; } } @@ -348,12 +359,20 @@ table.u2f-registrations { .landing { .svg-container, .user-callout-copy { - margin: 0; + margin: 0 auto; display: block; svg { height: 75px; } + + &.convdev { + margin: $gl-padding auto 0; + + svg { + height: 120px; + } + } } } } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 24ab2bedea2..a2f781a6a6e 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -29,6 +29,20 @@ & > .form-group { padding-left: 0; } + + select option[disabled] { + display: none; + } + } + + select { + background: transparent; + transition: background 2s ease-out; + + &.highlight-changes { + background: $highlight-changes-color; + transition: none; + } } .help-block { @@ -675,14 +689,16 @@ pre.light-well { } } -.new_protected_branch { +.new_protected_branch, +.new-protected-tag { label { margin-top: 6px; font-weight: normal; } } -.create-new-protected-branch-button { +.create-new-protected-branch-button, +.create-new-protected-tag-button { @include dropdown-link; width: 100%; diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 3889deee21a..33b3c083fd2 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -1,3 +1,90 @@ +@keyframes expandMaxHeight { + 0% { + max-height: 0; + } + + 99% { + max-height: 100vh; + } + + 100% { + max-height: none; + } +} + +@keyframes collapseMaxHeight { + 0% { + max-height: 100vh; + } + + 100% { + max-height: 0; + } +} + +.settings { + overflow: hidden; + border-bottom: 1px solid $gray-darker; + + &:first-of-type { + margin-top: 10px; + } +} + +.settings-header { + position: relative; + padding: 20px 110px 10px 0; + + h4 { + margin-top: 0; + } + + button { + position: absolute; + top: 20px; + right: 6px; + min-width: 80px; + } +} + +.settings-content { + max-height: 1px; + overflow-y: scroll; + margin-right: -20px; + padding-right: 130px; + animation: collapseMaxHeight 300ms ease-out; + + &.expanded { + max-height: none; + overflow-y: visible; + animation: expandMaxHeight 300ms ease-in; + } + + &.no-animate { + animation: none; + } + + @media(max-width: $screen-sm-max) { + padding-right: 20px; + } + + &::before { + content: ' '; + display: block; + height: 1px; + overflow: hidden; + margin-bottom: 4px; + } + + &::after { + content: ' '; + display: block; + height: 1px; + overflow: hidden; + margin-top: 20px; + } +} + .settings-list-icon { color: $gl-text-color-secondary; font-size: $settings-icon-size; |