diff options
author | Robert Speicher <rspeicher@gmail.com> | 2018-12-18 00:26:32 +0300 |
---|---|---|
committer | Robert Speicher <rspeicher@gmail.com> | 2018-12-18 00:26:32 +0300 |
commit | b0cb0d7fd9a4483441866261dcba214a3c94d8c6 (patch) | |
tree | f00b512c60dad216968aef203358e8c950766458 | |
parent | 9fd89ee372202f11164abfeef476fd12414ffdbe (diff) | |
parent | 8da9c710c0261d30eb731ff0109bfd7dbfabe17f (diff) |
Merge branch '11-6-stable-prepare-rc8-since-7th' into '11-6-stable-prepare-rc8'
Merge all CE commits between the 7th and the 10th into the RC8 stable branch
See merge request gitlab-org/gitlab-ce!23872
157 files changed, 2819 insertions, 558 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 46604317232..4ae319d64d7 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -488,7 +488,6 @@ danger-review: <<: *pull-cache image: registry.gitlab.com/gitlab-org/gitlab-build-images:danger stage: test - allow_failure: true dependencies: [] before_script: [] only: @@ -555,7 +554,8 @@ docs lint: # Build HTML from Markdown - bundle exec nanoc # Check the internal links - - bundle exec nanoc check internal_links + # Disabled until https://gitlab.com/gitlab-com/gitlab-docs/issues/305 is resolved + # - bundle exec nanoc check internal_links downtime_check: <<: *rake-exec diff --git a/.gitlab/CODEOWNERS b/.gitlab/CODEOWNERS.disabled index a4b773b15a9..82e914a502f 100644 --- a/.gitlab/CODEOWNERS +++ b/.gitlab/CODEOWNERS.disabled @@ -6,8 +6,8 @@ /doc/ @axil @marcia # Frontend maintainers should see everything in `app/assets/` -app/assets/ @ClemMakesApps @fatihacet @filipa @iamphill @mikegreiling @timzallmann -*.scss @annabeldunstone @ClemMakesApps @fatihacet @filipa @iamphill @mikegreiling @timzallmann +app/assets/ @ClemMakesApps @fatihacet @filipa @iamphill @mikegreiling @timzallmann @kushalpandya +*.scss @annabeldunstone @ClemMakesApps @fatihacet @filipa @iamphill @mikegreiling @timzallmann @kushalpandya # Someone from the database team should review changes in `db/` db/ @abrandl @NikolayS diff --git a/.gitlab/issue_templates/Feature proposal.md b/.gitlab/issue_templates/Feature proposal.md index c4065d3c4ea..ad517f0457d 100644 --- a/.gitlab/issue_templates/Feature proposal.md +++ b/.gitlab/issue_templates/Feature proposal.md @@ -1,14 +1,22 @@ ### Problem to solve +<!--- What problem do we solve? --> + +### Target audience + +<!--- For whom are we doing this? Include either a persona from https://design.gitlab.com/#/getting-started/personas or define a specific company role. e.a. "Release Manager" or "Security Analyst" --> + ### Further details -(Include use cases, benefits, and/or goals) +<!--- Include use cases, benefits, and/or goals (contributes to our vision?) --> ### Proposal +<!--- How are we going to solve the problem? --> + ### What does success look like, and how can we measure that? -(If no way to measure success, link to an issue that will implement a way to measure this) +<!--- If no way to measure success, link to an issue that will implement a way to measure this --> ### Links / references diff --git a/GITLAB_WORKHORSE_VERSION b/GITLAB_WORKHORSE_VERSION index 18bb4182dd0..93c8ddab9fe 100644 --- a/GITLAB_WORKHORSE_VERSION +++ b/GITLAB_WORKHORSE_VERSION @@ -1 +1 @@ -7.5.0 +7.6.0 diff --git a/Gemfile.lock b/Gemfile.lock index 608d1814127..430025c7bde 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -807,7 +807,7 @@ GEM selenium-webdriver (3.12.0) childprocess (~> 0.5) rubyzip (~> 1.2) - sentry-raven (2.7.2) + sentry-raven (2.7.4) faraday (>= 0.7.6, < 1.0) settingslogic (2.0.9) sexp_processor (4.11.0) diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index e5a628ab653..7607c4b3b79 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -21,7 +21,9 @@ const Api = { projectTemplatePath: '/api/:version/projects/:id/templates/:type/:key', projectTemplatesPath: '/api/:version/projects/:id/templates/:type', usersPath: '/api/:version/users.json', - userStatusPath: '/api/:version/user/status', + userPath: '/api/:version/users/:id', + userStatusPath: '/api/:version/users/:id/status', + userPostStatusPath: '/api/:version/user/status', commitPath: '/api/:version/projects/:id/repository/commits', applySuggestionPath: '/api/:version/suggestions/:id/apply', commitPipelinesPath: '/:project_id/commit/:sha/pipelines', @@ -261,6 +263,20 @@ const Api = { }); }, + user(id, options) { + const url = Api.buildUrl(this.userPath).replace(':id', encodeURIComponent(id)); + return axios.get(url, { + params: options, + }); + }, + + userStatus(id, options) { + const url = Api.buildUrl(this.userStatusPath).replace(':id', encodeURIComponent(id)); + return axios.get(url, { + params: options, + }); + }, + branches(id, query = '', options = {}) { const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id)); @@ -283,7 +299,7 @@ const Api = { }, postUserStatus({ emoji, message }) { - const url = Api.buildUrl(this.userStatusPath); + const url = Api.buildUrl(this.userPostStatusPath); return axios.put(url, { emoji, diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js index a2d4331b6d1..fc9286d15e6 100644 --- a/app/assets/javascripts/behaviors/markdown/render_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -3,6 +3,7 @@ import syntaxHighlight from '~/syntax_highlight'; import renderMath from './render_math'; import renderMermaid from './render_mermaid'; import highlightCurrentUser from './highlight_current_user'; +import initUserPopovers from '../../user_popovers'; // Render GitLab flavoured Markdown // @@ -13,6 +14,7 @@ $.fn.renderGFM = function renderGFM() { renderMath(this.find('.js-render-math')); renderMermaid(this.find('.js-render-mermaid')); highlightCurrentUser(this.find('.gfm-project_member').get()); + initUserPopovers(this.find('.gfm-project_member').get()); return this; }; diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue index 15937b1091a..e038198e6f0 100644 --- a/app/assets/javascripts/boards/components/issue_due_date.vue +++ b/app/assets/javascripts/boards/components/issue_due_date.vue @@ -15,6 +15,16 @@ export default { type: String, required: true, }, + cssClass: { + type: String, + required: false, + default: '', + }, + tooltipPlacement: { + type: String, + required: false, + default: 'bottom', + }, }, computed: { title() { @@ -66,15 +76,13 @@ export default { <template> <span> - <span ref="issueDueDate" class="board-card-info card-number"> - <icon - :class="{ 'text-danger': isPastDue, 'board-card-info-icon': true }" - name="calendar" - /><time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{ + <span ref="issueDueDate" :class="cssClass" class="board-card-info card-number"> + <icon :class="{ 'text-danger': isPastDue, 'board-card-info-icon': true }" name="calendar" /> + <time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{ body }}</time> </span> - <gl-tooltip :target="() => $refs.issueDueDate" placement="bottom"> + <gl-tooltip :target="() => $refs.issueDueDate" :placement="tooltipPlacement"> <span class="bold">{{ __('Due date') }}</span> <br /> <span :class="{ 'text-danger-muted': isPastDue }">{{ title }}</span> </gl-tooltip> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index a2b6caaa346..449f7007077 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -4,6 +4,7 @@ import _ from 'underscore'; import { __, sprintf } from '~/locale'; import createFlash from '~/flash'; import { GlLoadingIcon } from '@gitlab/ui'; +import eventHub from '../../notes/event_hub'; import DiffFileHeader from './diff_file_header.vue'; import DiffContent from './diff_content.vue'; @@ -80,6 +81,9 @@ export default { } }, }, + created() { + eventHub.$on(`loadCollapsedDiff/${this.file.file_hash}`, this.handleLoadCollapsedDiff); + }, methods: { ...mapActions('diffs', ['loadCollapsedDiff', 'assignDiscussionsToDiff']), handleToggle() { diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 952963e0711..00a4bb6d3a3 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -3,8 +3,9 @@ import axios from '~/lib/utils/axios_utils'; import Cookies from 'js-cookie'; import createFlash from '~/flash'; import { s__ } from '~/locale'; -import { handleLocationHash, historyPushState } from '~/lib/utils/common_utils'; +import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils'; import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility'; +import eventHub from '../../notes/event_hub'; import { getDiffPositionByLineCode, getNoteFormData } from './utils'; import * as types from './mutation_types'; import { @@ -53,6 +54,10 @@ export const assignDiscussionsToDiff = ( diffPositionByLineCode, }); }); + + Vue.nextTick(() => { + eventHub.$emit('scrollToDiscussion'); + }); }; export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => { @@ -60,6 +65,27 @@ export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => { commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash: file_hash, lineCode: line_code, id }); }; +export const renderFileForDiscussionId = ({ commit, rootState, state }, discussionId) => { + const discussion = rootState.notes.discussions.find(d => d.id === discussionId); + + if (discussion) { + const file = state.diffFiles.find(f => f.file_hash === discussion.diff_file.file_hash); + + if (file) { + if (!file.renderIt) { + commit(types.RENDER_FILE, file); + } + + if (file.collapsed) { + eventHub.$emit(`loadCollapsedDiff/${file.file_hash}`); + scrollToElement(document.getElementById(file.file_hash)); + } else { + eventHub.$emit('scrollToDiscussion'); + } + } + } +}; + export const startRenderDiffsQueue = ({ state, commit }) => { const checkItem = () => new Promise(resolve => { diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 816dcee4e00..2ea884d1293 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -159,7 +159,7 @@ export default { } if (!file.parallel_diff_lines || !file.highlighted_diff_lines) { - file.discussions = file.discussions.concat(discussion); + file.discussions = (file.discussions || []).concat(discussion); } return file; diff --git a/app/assets/javascripts/image_diff/helpers/badge_helper.js b/app/assets/javascripts/image_diff/helpers/badge_helper.js index eddaeda9578..000157efad0 100644 --- a/app/assets/javascripts/image_diff/helpers/badge_helper.js +++ b/app/assets/javascripts/image_diff/helpers/badge_helper.js @@ -12,7 +12,7 @@ export function createImageBadge(noteId, { x, y }, classNames = []) { } export function addImageBadge(containerEl, { coordinate, badgeText, noteId }) { - const buttonEl = createImageBadge(noteId, coordinate, ['badge']); + const buttonEl = createImageBadge(noteId, coordinate, ['badge', 'badge-pill']); buttonEl.innerText = badgeText; containerEl.appendChild(buttonEl); diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 040d0bc659e..9e22cdc04e9 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -192,8 +192,12 @@ export const contentTop = () => { const mrTabsHeight = $('.merge-request-tabs').height() || 0; const headerHeight = $('.navbar-gitlab').height() || 0; const diffFilesChanged = $('.js-diff-files-changed').height() || 0; + const diffFileLargeEnoughScreen = + 'matchMedia' in window ? window.matchMedia('min-width: 768') : true; + const diffFileTitleBar = + (diffFileLargeEnoughScreen && $('.diff-file .file-title-flex-parent:visible').height()) || 0; - return perfBar + mrTabsHeight + headerHeight + diffFilesChanged; + return perfBar + mrTabsHeight + headerHeight + diffFilesChanged + diffFileTitleBar; }; export const scrollToElement = element => { diff --git a/app/assets/javascripts/lib/utils/users_cache.js b/app/assets/javascripts/lib/utils/users_cache.js index c0d45e017b4..9f980fd4899 100644 --- a/app/assets/javascripts/lib/utils/users_cache.js +++ b/app/assets/javascripts/lib/utils/users_cache.js @@ -22,6 +22,34 @@ class UsersCache extends Cache { }); // missing catch is intentional, error handling depends on use case } + + retrieveById(userId) { + if (this.hasData(userId) && this.get(userId).username) { + return Promise.resolve(this.get(userId)); + } + + return Api.user(userId).then(({ data }) => { + this.internalStorage[userId] = data; + return data; + }); + // missing catch is intentional, error handling depends on use case + } + + retrieveStatusById(userId) { + if (this.hasData(userId) && this.get(userId).status) { + return Promise.resolve(this.get(userId).status); + } + + return Api.userStatus(userId).then(({ data }) => { + if (!this.hasData(userId)) { + this.internalStorage[userId] = {}; + } + this.internalStorage[userId].status = data; + + return data; + }); + // missing catch is intentional, error handling depends on use case + } } export default new UsersCache(); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index a88b575ad99..c866e8d180a 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -30,6 +30,7 @@ import initUsagePingConsent from './usage_ping_consent'; import initPerformanceBar from './performance_bar'; import initSearchAutocomplete from './search_autocomplete'; import GlFieldErrors from './gl_field_errors'; +import initUserPopovers from './user_popovers'; // expose jQuery as global (TODO: remove these) window.jQuery = jQuery; @@ -78,6 +79,7 @@ document.addEventListener('DOMContentLoaded', () => { initTodoToggle(); initLogoAnimation(); initUsagePingConsent(); + initUserPopovers(); if (document.querySelector('.search')) initSearchAutocomplete(); if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' }); diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue new file mode 100644 index 00000000000..12224e36ba2 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/charts/area.vue @@ -0,0 +1,97 @@ +<script> +import { GlAreaChart } from '@gitlab/ui'; +import dateFormat from 'dateformat'; + +export default { + components: { + GlAreaChart, + }, + props: { + graphData: { + type: Object, + required: true, + validator(data) { + return ( + data.queries && + Array.isArray(data.queries) && + data.queries.filter(query => { + if (Array.isArray(query.result)) { + return ( + query.result.filter(res => Array.isArray(res.values)).length === query.result.length + ); + } + return false; + }).length === data.queries.length + ); + }, + }, + }, + computed: { + chartData() { + return this.graphData.queries.reduce((accumulator, query) => { + const xLabel = `${query.unit}`; + accumulator[xLabel] = {}; + query.result.forEach(res => + res.values.forEach(v => { + accumulator[xLabel][v.time.toISOString()] = v.value; + }), + ); + return accumulator; + }, {}); + }, + chartOptions() { + return { + xAxis: { + name: 'Time', + type: 'time', + axisLabel: { + formatter: date => dateFormat(date, 'h:MMtt'), + }, + nameTextStyle: { + padding: [18, 0, 0, 0], + }, + }, + yAxis: { + name: this.graphData.y_label, + axisLabel: { + formatter: value => value.toFixed(3), + }, + nameTextStyle: { + padding: [0, 0, 36, 0], + }, + }, + legend: { + formatter: this.xAxisLabel, + }, + }; + }, + xAxisLabel() { + return this.graphData.queries.map(query => query.label).join(', '); + }, + }, + methods: { + formatTooltipText(params) { + const [date, value] = params; + return [dateFormat(date, 'dd mmm yyyy, h:MMtt'), value.toFixed(3)]; + }, + onCreated(chart) { + this.$emit('created', chart); + }, + }, +}; +</script> + +<template> + <div class="prometheus-graph"> + <div class="prometheus-graph-header"> + <h5 class="prometheus-graph-title">{{ graphData.title }}</h5> + <div class="prometheus-graph-widgets"><slot></slot></div> + </div> + <gl-area-chart + :data="chartData" + :option="chartOptions" + :format-tooltip-text="formatTooltipText" + @created="onCreated" + /> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 218c508a608..2d9c5050c9b 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -4,6 +4,7 @@ import { s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import Flash from '../../flash'; import MonitoringService from '../services/monitoring_service'; +import MonitorAreaChart from './charts/area.vue'; import GraphGroup from './graph_group.vue'; import Graph from './graph.vue'; import EmptyState from './empty_state.vue'; @@ -12,6 +13,7 @@ import eventHub from '../event_hub'; export default { components: { + MonitorAreaChart, Graph, GraphGroup, EmptyState, @@ -102,6 +104,9 @@ export default { }; }, computed: { + graphComponent() { + return gon.features && gon.features.areaChart ? MonitorAreaChart : Graph; + }, forceRedraw() { return this.elWidth; }, @@ -207,7 +212,8 @@ export default { :name="groupData.group" :show-panels="showPanels" > - <graph + <component + :is="graphComponent" v-for="(graphData, graphIndex) in groupData.metrics" :key="graphIndex" :graph-data="graphData" @@ -220,7 +226,7 @@ export default { > <!-- EE content --> {{ null }} - </graph> + </component> </graph-group> </div> <empty-state diff --git a/app/assets/javascripts/notes/components/note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue index 3d3dbbd7fe1..15ce49d7c31 100644 --- a/app/assets/javascripts/notes/components/note_edited_text.vue +++ b/app/assets/javascripts/notes/components/note_edited_text.vue @@ -39,7 +39,10 @@ export default { <div :class="className"> {{ actionText }} <template v-if="editedBy"> - by <a :href="editedBy.path" class="js-vue-author author-link"> {{ editedBy.name }} </a> + by + <a :href="editedBy.path" :data-user-id="editedBy.id" class="js-user-link author-link"> + {{ editedBy.name }} + </a> </template> {{ actionDetailText }} <time-ago-tooltip :time="editedAt" tooltip-placement="bottom" /> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index e1a58e7cb26..7b39901024d 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -73,7 +73,14 @@ export default { {{ __('Toggle discussion') }} </button> </div> - <a v-if="hasAuthor" v-once :href="author.path"> + <a + v-if="hasAuthor" + v-once + :href="author.path" + class="js-user-link" + :data-user-id="author.id" + :data-username="author.username" + > <span class="note-header-author-name">{{ author.name }}</span> <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span> <span class="note-headline-light"> @{{ author.username }} </span> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 7d38e72c3ff..e540e7326fd 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -91,6 +91,7 @@ export default { 'nextUnresolvedDiscussionId', 'unresolvedDiscussionsCount', 'hasUnresolvedDiscussions', + 'showJumpToNextDiscussion', ]), author() { return this.initialDiscussion.author; @@ -131,6 +132,12 @@ export default { resolvedText() { return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved'); }, + shouldShowJumpToNextDiscussion() { + return this.showJumpToNextDiscussion( + this.discussion.id, + this.discussionsByDiffOrder ? 'diff' : 'discussion', + ); + }, shouldRenderDiffs() { return this.discussion.diff_discussion && this.renderDiffFile; }, @@ -441,7 +448,7 @@ Please check your network connection and try again.`; <icon name="issue-new" /> </a> </div> - <div v-if="hasUnresolvedDiscussions" class="btn-group" role="group"> + <div v-if="shouldShowJumpToNextDiscussion" class="btn-group" role="group"> <button v-gl-tooltip class="btn btn-default discussion-next-btn" diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 506cc82db4b..f3fcfdfda05 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -12,6 +12,7 @@ import placeholderNote from '../../vue_shared/components/notes/placeholder_note. import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; +import initUserPopovers from '../../user_popovers'; export default { name: 'NotesApp', @@ -111,7 +112,10 @@ export default { } }, updated() { - this.$nextTick(() => highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member'))); + this.$nextTick(() => { + highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member')); + initUserPopovers(this.$el.querySelectorAll('.js-user-link')); + }); }, methods: { ...mapActions([ diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index f7c4deee1f8..3d89d907777 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -1,29 +1,56 @@ import { scrollToElement } from '~/lib/utils/common_utils'; +import eventHub from '../../notes/event_hub'; export default { methods: { - jumpToDiscussion(id) { - if (id) { - const activeTab = window.mrTabs.currentAction; - const selector = - activeTab === 'diffs' - ? `ul.notes[data-discussion-id="${id}"]` - : `div.discussion[data-discussion-id="${id}"]`; - const el = document.querySelector(selector); + diffsJump(id) { + const selector = `ul.notes[data-discussion-id="${id}"]`; - if (activeTab === 'commits' || activeTab === 'pipelines') { - window.mrTabs.activateTab('show'); - } + eventHub.$once('scrollToDiscussion', () => { + const el = document.querySelector(selector); if (el) { - this.expandDiscussion({ discussionId: id }); - scrollToElement(el); + return true; } + + return false; + }); + + this.expandDiscussion({ discussionId: id }); + }, + discussionJump(id) { + const selector = `div.discussion[data-discussion-id="${id}"]`; + + const el = document.querySelector(selector); + + this.expandDiscussion({ discussionId: id }); + + if (el) { + scrollToElement(el); + + return true; } return false; }, + jumpToDiscussion(id) { + if (id) { + const activeTab = window.mrTabs.currentAction; + + if (activeTab === 'diffs') { + this.diffsJump(id); + } else if (activeTab === 'commits' || activeTab === 'pipelines') { + window.mrTabs.eventHub.$once('MergeRequestTabChange', () => { + setTimeout(() => this.discussionJump(id), 0); + }); + + window.mrTabs.tabShown('show'); + } else { + this.discussionJump(id); + } + } + }, }, }; diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 062a1676ade..65f85314fa0 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -17,7 +17,13 @@ import { __ } from '~/locale'; let eTagPoll; -export const expandDiscussion = ({ commit }, data) => commit(types.EXPAND_DISCUSSION, data); +export const expandDiscussion = ({ commit, dispatch }, data) => { + if (data.discussionId) { + dispatch('diffs/renderFileForDiscussionId', data.discussionId, { root: true }); + } + + commit(types.EXPAND_DISCUSSION, data); +}; export const collapseDiscussion = ({ commit }, data) => commit(types.COLLAPSE_DISCUSSION, data); diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 2ed8aac059a..0ffc0cb2593 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -57,6 +57,17 @@ export const unresolvedDiscussionsCount = state => state.unresolvedDiscussionsCo export const resolvableDiscussionsCount = state => state.resolvableDiscussionsCount; export const hasUnresolvedDiscussions = state => state.hasUnresolvedDiscussions; +export const showJumpToNextDiscussion = (state, getters) => (discussionId, mode = 'discussion') => { + const orderedDiffs = + mode !== 'discussion' + ? getters.unresolvedDiscussionsIdsByDiff + : getters.unresolvedDiscussionsIdsByDate; + + const indexOf = orderedDiffs.indexOf(discussionId); + + return indexOf !== -1 && indexOf < orderedDiffs.length - 1; +}; + export const isDiscussionResolved = (state, getters) => discussionId => getters.resolvedDiscussionsById[discussionId] !== undefined; @@ -104,7 +115,7 @@ export const unresolvedDiscussionsIdsByDate = (state, getters) => // line numbers. export const unresolvedDiscussionsIdsByDiff = (state, getters) => getters.allResolvableDiscussions - .filter(d => !d.resolved) + .filter(d => !d.resolved && d.active) .sort((a, b) => { if (!a.diff_file || !b.diff_file) { return 0; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index b84bd20fc59..8992454be2e 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -22,6 +22,7 @@ export default { if (isDiscussion && isInMRPage()) { noteData.resolvable = note.resolvable; noteData.resolved = false; + noteData.active = true; noteData.resolve_path = note.resolve_path; noteData.resolve_with_issue_path = note.resolve_with_issue_path; noteData.diff_discussion = false; diff --git a/app/assets/javascripts/registry/components/app.vue b/app/assets/javascripts/registry/components/app.vue index 6233fb169e9..9af5660f764 100644 --- a/app/assets/javascripts/registry/components/app.vue +++ b/app/assets/javascripts/registry/components/app.vue @@ -1,15 +1,13 @@ <script> import { mapGetters, mapActions } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; -import Flash from '../../flash'; import store from '../stores'; -import collapsibleContainer from './collapsible_container.vue'; -import { errorMessages, errorMessagesTypes } from '../constants'; +import CollapsibleContainer from './collapsible_container.vue'; export default { name: 'RegistryListApp', components: { - collapsibleContainer, + CollapsibleContainer, GlLoadingIcon, }, props: { @@ -26,7 +24,7 @@ export default { this.setMainEndpoint(this.endpoint); }, mounted() { - this.fetchRepos().catch(() => Flash(errorMessages[errorMessagesTypes.FETCH_REPOS])); + this.fetchRepos(); }, methods: { ...mapActions(['setMainEndpoint', 'fetchRepos']), @@ -38,9 +36,9 @@ export default { <gl-loading-icon v-if="isLoading" :size="3" /> <collapsible-container - v-for="(item, index) in repos" + v-for="item in repos" v-else-if="!isLoading && repos.length" - :key="index" + :key="item.id" :repo="item" /> diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue index 6514c05a9c7..5451c61026c 100644 --- a/app/assets/javascripts/registry/components/collapsible_container.vue +++ b/app/assets/javascripts/registry/components/collapsible_container.vue @@ -1,22 +1,24 @@ <script> import { mapActions } from 'vuex'; -import { GlLoadingIcon } from '@gitlab/ui'; -import Flash from '../../flash'; -import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; -import tooltip from '../../vue_shared/directives/tooltip'; -import tableRegistry from './table_registry.vue'; +import { GlLoadingIcon, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import createFlash from '../../flash'; +import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; +import Icon from '../../vue_shared/components/icon.vue'; +import TableRegistry from './table_registry.vue'; import { errorMessages, errorMessagesTypes } from '../constants'; import { __ } from '../../locale'; export default { name: 'CollapsibeContainerRegisty', components: { - clipboardButton, - tableRegistry, + ClipboardButton, + TableRegistry, GlLoadingIcon, + GlButton, + Icon, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { repo: { @@ -29,30 +31,30 @@ export default { isOpen: false, }; }, + computed: { + iconName() { + return this.isOpen ? 'angle-up' : 'angle-right'; + }, + }, methods: { ...mapActions(['fetchRepos', 'fetchList', 'deleteRepo']), - toggleRepo() { this.isOpen = !this.isOpen; if (this.isOpen) { - this.fetchList({ repo: this.repo }).catch(() => - this.showError(errorMessagesTypes.FETCH_REGISTRY), - ); + this.fetchList({ repo: this.repo }); } }, - handleDeleteRepository() { this.deleteRepo(this.repo) .then(() => { - Flash(__('This container registry has been scheduled for deletion.'), 'notice'); + createFlash(__('This container registry has been scheduled for deletion.'), 'notice'); this.fetchRepos(); }) .catch(() => this.showError(errorMessagesTypes.DELETE_REPO)); }, - showError(message) { - Flash(errorMessages[message]); + createFlash(errorMessages[message]); }, }, }; @@ -61,18 +63,9 @@ export default { <template> <div class="container-image"> <div class="container-image-head"> - <button type="button" class="js-toggle-repo btn-link" @click="toggleRepo"> - <i - :class="{ - 'fa-chevron-right': !isOpen, - 'fa-chevron-up': isOpen, - }" - class="fa" - aria-hidden="true" - > - </i> - {{ repo.name }} - </button> + <gl-button class="js-toggle-repo btn-link align-baseline" @click="toggleRepo"> + <icon :name="iconName" /> {{ repo.name }} + </gl-button> <clipboard-button v-if="repo.location" @@ -82,17 +75,17 @@ export default { /> <div class="controls d-none d-sm-block float-right"> - <button + <gl-button v-if="repo.canDelete" - v-tooltip + v-gl-tooltip :title="s__('ContainerRegistry|Remove repository')" :aria-label="s__('ContainerRegistry|Remove repository')" - type="button" - class="js-remove-repo btn btn-danger" + class="js-remove-repo" + variant="danger" @click="handleDeleteRepository" > - <i class="fa fa-trash" aria-hidden="true"> </i> - </button> + <icon name="remove" /> + </gl-button> </div> </div> diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue index 6735c3ff7cf..78c7671856a 100644 --- a/app/assets/javascripts/registry/components/table_registry.vue +++ b/app/assets/javascripts/registry/components/table_registry.vue @@ -1,21 +1,24 @@ <script> import { mapActions } from 'vuex'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { n__ } from '../../locale'; -import Flash from '../../flash'; -import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; -import tablePagination from '../../vue_shared/components/table_pagination.vue'; -import tooltip from '../../vue_shared/directives/tooltip'; +import createFlash from '../../flash'; +import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; +import TablePagination from '../../vue_shared/components/table_pagination.vue'; +import Icon from '../../vue_shared/components/icon.vue'; import timeagoMixin from '../../vue_shared/mixins/timeago'; import { errorMessages, errorMessagesTypes } from '../constants'; import { numberToHumanSize } from '../../lib/utils/number_utils'; export default { components: { - clipboardButton, - tablePagination, + ClipboardButton, + TablePagination, + GlButton, + Icon, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, mixins: [timeagoMixin], props: { @@ -31,29 +34,24 @@ export default { }, methods: { ...mapActions(['fetchList', 'deleteRegistry']), - layers(item) { return item.layers ? n__('%d layer', '%d layers', item.layers) : ''; }, - formatSize(size) { return numberToHumanSize(size); }, - handleDeleteRegistry(registry) { this.deleteRegistry(registry) .then(() => this.fetchList({ repo: this.repo })) .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY)); }, - onPageChange(pageNumber) { this.fetchList({ repo: this.repo, page: pageNumber }).catch(() => this.showError(errorMessagesTypes.FETCH_REGISTRY), ); }, - showError(message) { - Flash(errorMessages[message]); + createFlash(errorMessages[message]); }, }, }; @@ -71,10 +69,9 @@ export default { </tr> </thead> <tbody> - <tr v-for="(item, i) in repo.list" :key="i"> + <tr v-for="item in repo.list" :key="item.tag"> <td> {{ item.tag }} - <clipboard-button v-if="item.location" :title="item.location" @@ -83,37 +80,34 @@ export default { /> </td> <td> - <span v-tooltip :title="item.revision" data-placement="bottom"> - {{ item.shortRevision }} - </span> + <span v-gl-tooltip.bottom :title="item.revision">{{ item.shortRevision }}</span> </td> <td> {{ formatSize(item.size) }} - <template v-if="item.size && item.layers"> - · - </template> + <template v-if="item.size && item.layers" + >·</template + > {{ layers(item) }} </td> <td> - <span v-tooltip :title="tooltipTitle(item.createdAt)" data-placement="bottom"> - {{ timeFormated(item.createdAt) }} - </span> + <span v-gl-tooltip.bottom :title="tooltipTitle(item.createdAt)">{{ + timeFormated(item.createdAt) + }}</span> </td> <td class="content"> - <button + <gl-button v-if="item.canDelete" - v-tooltip + v-gl-tooltip :title="s__('ContainerRegistry|Remove tag')" :aria-label="s__('ContainerRegistry|Remove tag')" - type="button" - class="js-delete-registry btn btn-danger d-none d-sm-block float-right" - data-container="body" + variant="danger" + class="js-delete-registry d-none d-sm-block float-right" @click="handleDeleteRegistry(item);" > - <i class="fa fa-trash" aria-hidden="true"> </i> - </button> + <icon name="remove" /> + </gl-button> </td> </tr> </tbody> diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/stores/actions.js index a78aa90b7b5..51d057c62c1 100644 --- a/app/assets/javascripts/registry/stores/actions.js +++ b/app/assets/javascripts/registry/stores/actions.js @@ -1,39 +1,45 @@ -import Vue from 'vue'; -import VueResource from 'vue-resource'; +import axios from '~/lib/utils/axios_utils'; +import createFlash from '~/flash'; import * as types from './mutation_types'; - -Vue.use(VueResource); +import { errorMessages, errorMessagesTypes } from '../constants'; export const fetchRepos = ({ commit, state }) => { commit(types.TOGGLE_MAIN_LOADING); - return Vue.http + return axios .get(state.endpoint) - .then(res => res.json()) - .then(response => { + .then(({ data }) => { + commit(types.TOGGLE_MAIN_LOADING); + commit(types.SET_REPOS_LIST, data); + }) + .catch(() => { commit(types.TOGGLE_MAIN_LOADING); - commit(types.SET_REPOS_LIST, response); + createFlash(errorMessages[errorMessagesTypes.FETCH_REPOS]); }); }; export const fetchList = ({ commit }, { repo, page }) => { commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo); - return Vue.http.get(repo.tagsPath, { params: { page } }).then(response => { - const { headers } = response; + return axios + .get(repo.tagsPath, { params: { page } }) + .then(response => { + const { headers, data } = response; - return response.json().then(resp => { commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo); - commit(types.SET_REGISTRY_LIST, { repo, resp, headers }); + commit(types.SET_REGISTRY_LIST, { repo, resp: data, headers }); + }) + .catch(() => { + commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo); + createFlash(errorMessages[errorMessagesTypes.FETCH_REGISTRY]); }); - }); }; // eslint-disable-next-line no-unused-vars -export const deleteRepo = ({ commit }, repo) => Vue.http.delete(repo.destroyPath); +export const deleteRepo = ({ commit }, repo) => axios.delete(repo.destroyPath); // eslint-disable-next-line no-unused-vars -export const deleteRegistry = ({ commit }, image) => Vue.http.delete(image.destroyPath); +export const deleteRegistry = ({ commit }, image) => axios.delete(image.destroyPath); export const setMainEndpoint = ({ commit }, data) => commit(types.SET_MAIN_ENDPOINT, data); export const toggleLoading = ({ commit }) => commit(types.TOGGLE_MAIN_LOADING); diff --git a/app/assets/javascripts/registry/stores/index.js b/app/assets/javascripts/registry/stores/index.js index 78b67881210..1bb06bd6e81 100644 --- a/app/assets/javascripts/registry/stores/index.js +++ b/app/assets/javascripts/registry/stores/index.js @@ -3,36 +3,12 @@ import Vuex from 'vuex'; import * as actions from './actions'; import * as getters from './getters'; import mutations from './mutations'; +import createState from './state'; Vue.use(Vuex); export default new Vuex.Store({ - state: { - isLoading: false, - endpoint: '', // initial endpoint to fetch the repos list - /** - * Each object in `repos` has the following strucure: - * { - * name: String, - * isLoading: Boolean, - * tagsPath: String // endpoint to request the list - * destroyPath: String // endpoit to delete the repo - * list: Array // List of the registry images - * } - * - * Each registry image inside `list` has the following structure: - * { - * tag: String, - * revision: String - * shortRevision: String - * size: Number - * layers: Number - * createdAt: String - * destroyPath: String // endpoit to delete each image - * } - */ - repos: [], - }, + state: createState(), actions, getters, mutations, diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/stores/mutations.js index 69c051cd2d6..1ac699c538f 100644 --- a/app/assets/javascripts/registry/stores/mutations.js +++ b/app/assets/javascripts/registry/stores/mutations.js @@ -48,6 +48,7 @@ export default { [types.TOGGLE_REGISTRY_LIST_LOADING](state, list) { const listToUpdate = state.repos.find(el => el.id === list.id); + listToUpdate.isLoading = !listToUpdate.isLoading; }, }; diff --git a/app/assets/javascripts/registry/stores/state.js b/app/assets/javascripts/registry/stores/state.js new file mode 100644 index 00000000000..feeac10cbe1 --- /dev/null +++ b/app/assets/javascripts/registry/stores/state.js @@ -0,0 +1,26 @@ +export default () => ({ + isLoading: false, + endpoint: '', // initial endpoint to fetch the repos list + /** + * Each object in `repos` has the following strucure: + * { + * name: String, + * isLoading: Boolean, + * tagsPath: String // endpoint to request the list + * destroyPath: String // endpoit to delete the repo + * list: Array // List of the registry images + * } + * + * Each registry image inside `list` has the following structure: + * { + * tag: String, + * revision: String + * shortRevision: String + * size: Number + * layers: Number + * createdAt: String + * destroyPath: String // endpoit to delete each image + * } + */ + repos: [], +}); diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js new file mode 100644 index 00000000000..948f4d5e631 --- /dev/null +++ b/app/assets/javascripts/user_popovers.js @@ -0,0 +1,107 @@ +import Vue from 'vue'; + +import UsersCache from './lib/utils/users_cache'; +import UserPopover from './vue_shared/components/user_popover/user_popover.vue'; + +let renderedPopover; +let renderFn; + +const handleUserPopoverMouseOut = event => { + const { target } = event; + target.removeEventListener('mouseleave', handleUserPopoverMouseOut); + + if (renderFn) { + clearTimeout(renderFn); + } + if (renderedPopover) { + renderedPopover.$destroy(); + renderedPopover = null; + } +}; + +/** + * Adds a UserPopover component to the body, hands over as much data as the target element has in data attributes. + * loads based on data-user-id more data about a user from the API and sets it on the popover + */ +const handleUserPopoverMouseOver = event => { + const { target } = event; + // Add listener to actually remove it again + target.addEventListener('mouseleave', handleUserPopoverMouseOut); + + renderFn = setTimeout(() => { + // Helps us to use current markdown setup without maybe breaking or duplicating for now + if (target.dataset.user) { + target.dataset.userId = target.dataset.user; + // Removing titles so its not showing tooltips also + target.dataset.originalTitle = ''; + target.setAttribute('title', ''); + } + + const { userId, username, name, avatarUrl } = target.dataset; + const user = { + userId, + username, + name, + avatarUrl, + location: null, + bio: null, + organization: null, + status: null, + loaded: false, + }; + if (userId || username) { + const UserPopoverComponent = Vue.extend(UserPopover); + renderedPopover = new UserPopoverComponent({ + propsData: { + target, + user, + }, + }); + + renderedPopover.$mount(); + + UsersCache.retrieveById(userId) + .then(userData => { + if (!userData) { + return; + } + + Object.assign(user, { + avatarUrl: userData.avatar_url, + username: userData.username, + name: userData.name, + location: userData.location, + bio: userData.bio, + organization: userData.organization, + loaded: true, + }); + + UsersCache.retrieveStatusById(userId) + .then(status => { + if (!status) { + return; + } + + Object.assign(user, { + status, + }); + }) + .catch(() => { + throw new Error(`User status for "${userId}" could not be retrieved!`); + }); + }) + .catch(() => { + renderedPopover.$destroy(); + renderedPopover = null; + }); + } + }, 200); // 200ms delay so not every mouseover triggers Popover + API Call +}; + +export default elements => { + const userLinks = elements || [...document.querySelectorAll('.js-user-link')]; + + userLinks.forEach(el => { + el.addEventListener('mouseenter', handleUserPopoverMouseOver); + }); +}; diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue new file mode 100644 index 00000000000..7e79e63aa1e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue @@ -0,0 +1,94 @@ +<script> +import { GlTooltipDirective } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; + +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; + +export default { + components: { + UserAvatarLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + assignees: { + type: Array, + required: true, + }, + }, + data() { + return { + maxVisibleAssignees: 2, + maxAssigneeAvatars: 3, + maxAssignees: 99, + }; + }, + computed: { + countOverLimit() { + return this.assignees.length - this.maxVisibleAssignees; + }, + assigneesToShow() { + if (this.assignees.length > this.maxAssigneeAvatars) { + return this.assignees.slice(0, this.maxVisibleAssignees); + } + return this.assignees; + }, + assigneesCounterTooltip() { + const { countOverLimit, maxAssignees } = this; + const count = countOverLimit > maxAssignees ? maxAssignees : countOverLimit; + + return sprintf(__('%{count} more assignees'), { count }); + }, + shouldRenderAssigneesCounter() { + const assigneesCount = this.assignees.length; + if (assigneesCount <= this.maxAssigneeAvatars) { + return false; + } + + return assigneesCount > this.countOverLimit; + }, + assigneeCounterLabel() { + if (this.countOverLimit > this.maxAssignees) { + return `${this.maxAssignees}+`; + } + + return `+${this.countOverLimit}`; + }, + }, + methods: { + avatarUrlTitle(assignee) { + return sprintf(__('Avatar for %{assigneeName}'), { + assigneeName: assignee.name, + }); + }, + }, +}; +</script> +<template> + <div class="issue-assignees"> + <user-avatar-link + v-for="assignee in assigneesToShow" + :key="assignee.id" + :link-href="assignee.web_url" + :img-alt="avatarUrlTitle(assignee)" + :img-src="assignee.avatar_url" + :img-size="24" + class="js-no-trigger" + tooltip-placement="bottom" + > + <span class="js-assignee-tooltip"> + <span class="bold d-block">{{ __('Assignee') }}</span> {{ assignee.name }} + <span class="text-white-50">@{{ assignee.username }}</span> + </span> + </user-avatar-link> + <span + v-if="shouldRenderAssigneesCounter" + v-gl-tooltip + :title="assigneesCounterTooltip" + class="avatar-counter" + data-placement="bottom" + >{{ assigneeCounterLabel }}</span + > + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue new file mode 100644 index 00000000000..d5d967e25bf --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue @@ -0,0 +1,90 @@ +<script> +import { GlTooltip } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { timeFor, parsePikadayDate, dateInWords } from '~/lib/utils/datetime_utility'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + Icon, + GlTooltip, + }, + mixins: [timeagoMixin], + props: { + milestone: { + type: Object, + required: true, + }, + }, + data() { + return { + milestoneDue: this.milestone.due_date ? parsePikadayDate(this.milestone.due_date) : null, + milestoneStart: this.milestone.start_date + ? parsePikadayDate(this.milestone.start_date) + : null, + }; + }, + computed: { + isMilestoneStarted() { + if (!this.milestoneStart) { + return false; + } + return Date.now() > this.milestoneStart; + }, + isMilestonePastDue() { + if (!this.milestoneDue) { + return false; + } + return Date.now() > this.milestoneDue; + }, + milestoneDatesAbsolute() { + if (this.milestoneDue) { + return `(${dateInWords(this.milestoneDue)})`; + } else if (this.milestoneStart) { + return `(${dateInWords(this.milestoneStart)})`; + } + return ''; + }, + milestoneDatesHuman() { + if (this.milestoneStart || this.milestoneDue) { + if (this.milestoneDue) { + return timeFor( + this.milestoneDue, + sprintf(__('Expired %{expiredOn}'), { + expiredOn: this.timeFormated(this.milestoneDue), + }), + ); + } + + return sprintf( + this.isMilestoneStarted ? __('Started %{startsIn}') : __('Starts %{startsIn}'), + { + startsIn: this.timeFormated(this.milestoneStart), + }, + ); + } + return ''; + }, + }, +}; +</script> +<template> + <div ref="milestoneDetails" class="issue-milestone-details"> + <icon :size="16" class="inline icon" name="clock" /> + <span class="milestone-title">{{ milestone.title }}</span> + <gl-tooltip :target="() => $refs.milestoneDetails" placement="bottom" class="js-item-milestone"> + <span class="bold">{{ __('Milestone') }}</span> <br /> + <span>{{ milestone.title }}</span> <br /> + <span + v-if="milestoneStart || milestoneDue" + :class="{ + 'text-danger-muted': isMilestonePastDue, + 'text-tertiary': !isMilestonePastDue, + }" + ><span>{{ milestoneDatesHuman }}</span + ><br /><span>{{ milestoneDatesAbsolute }}</span> + </span> + </gl-tooltip> + </div> +</template> 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 01b8b94f9e3..e833a8e0483 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 @@ -67,7 +67,7 @@ export default { // In both cases we should render the defaultAvatarUrl sanitizedSource() { let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; - if (baseSrc.indexOf('?') === -1) baseSrc += `?width=${this.size}`; + if (!baseSrc.startsWith('data:') && !baseSrc.includes('?')) baseSrc += `?width=${this.size}`; return baseSrc; }, resultantSrcAttribute() { @@ -97,6 +97,7 @@ export default { class="avatar" /> <gl-tooltip + v-if="tooltipText || $slots.default" :target="() => $refs.userAvatarImage" :placement="tooltipPlacement" boundary="window" diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue new file mode 100644 index 00000000000..7fbadcc0111 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -0,0 +1,104 @@ +<script> +import { GlPopover, GlSkeletonLoading } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; +import { glEmojiTag } from '../../../emoji'; + +export default { + name: 'UserPopover', + components: { + GlPopover, + GlSkeletonLoading, + UserAvatarImage, + }, + props: { + target: { + type: HTMLAnchorElement, + required: true, + }, + user: { + type: Object, + required: true, + default: null, + }, + loaded: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + jobLine() { + if (this.user.bio && this.user.organization) { + return sprintf(__('%{bio} at %{organization}'), { + bio: this.user.bio, + organization: this.user.organization, + }); + } else if (this.user.bio) { + return this.user.bio; + } else if (this.user.organization) { + return this.user.organization; + } + return null; + }, + statusHtml() { + if (this.user.status.emoji && this.user.status.message) { + return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message}`; + } else if (this.user.status.message) { + return this.user.status.message; + } + return ''; + }, + nameIsLoading() { + return !this.user.name; + }, + jobInfoIsLoading() { + return !this.user.loaded && this.user.organization === null; + }, + locationIsLoading() { + return !this.user.loaded && this.user.location === null; + }, + }, +}; +</script> + +<template> + <gl-popover :target="target" boundary="viewport" placement="top" show> + <div class="user-popover d-flex"> + <div class="p-1 flex-shrink-1"> + <user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="mr-2" /> + </div> + <div class="p-1 w-100"> + <h5 class="m-0"> + {{ user.name }} + <gl-skeleton-loading + v-if="nameIsLoading" + :lines="1" + class="animation-container-small mb-1" + /> + </h5> + <div class="text-secondary mb-2"> + <span v-if="user.username">@{{ user.username }}</span> + <gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" /> + </div> + <div class="text-secondary"> + {{ jobLine }} + <gl-skeleton-loading + v-if="jobInfoIsLoading" + :lines="1" + class="animation-container-small mb-1" + /> + </div> + <div class="text-secondary"> + {{ user.location }} + <gl-skeleton-loading + v-if="locationIsLoading" + :lines="1" + class="animation-container-small mb-1" + /> + </div> + <div v-if="user.status" class="mt-2"><span v-html="statusHtml"></span></div> + </div> + </div> + </gl-popover> +</template> diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index bd1cca69c03..985fac11c87 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -35,6 +35,11 @@ @import "pages/**/*"; /* + * Component specific styles, will be moved to gitlab-ui + */ +@import "components/**/*"; + +/* * Code highlight */ @import "highlight/dark"; diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index 62024b8c555..f0671e36130 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -18,8 +18,10 @@ $input-border: $border-color; $padding-base-vertical: $gl-vert-padding; $padding-base-horizontal: $gl-padding; -html { - // Override default font size used in bs4 +body, +.form-control, +.search form { + // Override default font size used in non-csslab UI font-size: 14px; } diff --git a/app/assets/stylesheets/components/popover.scss b/app/assets/stylesheets/components/popover.scss new file mode 100644 index 00000000000..2f4d30fe923 --- /dev/null +++ b/app/assets/stylesheets/components/popover.scss @@ -0,0 +1,9 @@ +.popover { + min-width: 300px; + + .popover-body .user-popover { + padding: $gl-padding-8; + font-size: $gl-font-size-small; + line-height: $gl-line-height; + } +} diff --git a/app/assets/stylesheets/csslab.scss b/app/assets/stylesheets/csslab.scss new file mode 100644 index 00000000000..acaa41e2677 --- /dev/null +++ b/app/assets/stylesheets/csslab.scss @@ -0,0 +1 @@ +@import "../../../node_modules/@gitlab/csslab/dist/css/csslab-slim"; diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index f3c44f32d6f..f273eb9533d 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -176,9 +176,9 @@ display: block; font-weight: $gl-font-weight-normal; position: relative; - padding: 8px 16px; + padding: $dropdown-item-padding-y $dropdown-item-padding-x; color: $gl-text-color; - line-height: normal; + line-height: $gl-btn-line-height; white-space: normal; overflow: hidden; text-align: left; @@ -319,8 +319,8 @@ .dropdown-header { color: $gl-text-color-secondary; font-size: 13px; - line-height: 22px; - padding: 8px 16px; + line-height: $gl-line-height; + padding: $dropdown-item-padding-y $dropdown-item-padding-x; } &.capitalize-header .dropdown-header { @@ -329,13 +329,8 @@ .dropdown-bold-header { font-weight: $gl-font-weight-bold; - line-height: 22px; - padding: 0 16px; - } - - .separator + .dropdown-header, - .separator + .dropdown-bold-header { - padding-top: 10px; + line-height: $gl-line-height; + padding: $dropdown-item-padding-y $dropdown-item-padding-x; } .unclickable { diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 037a5adfb7e..3ac7b6b704b 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -24,7 +24,7 @@ } } - table { + &:not(.use-csslab) table { @extend .table; } diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index c0cda29e239..45a52d99302 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -90,12 +90,6 @@ padding: 2px 8px; margin: 5px 2px 5px -8px; border-radius: $border-radius-default; - - .tanuki-logo { - @include media-breakpoint-up(sm) { - margin-right: 8px; - } - } } .project-item-select { @@ -127,12 +121,6 @@ } } - li.dropdown-bold-header { - color: $gl-text-color-secondary; - font-size: 12px; - padding: 0 16px; - } - .navbar-collapse { flex: 0 0 auto; border-top: 0; @@ -541,7 +529,7 @@ left: auto; li.current-user { - padding: 5px 18px; + padding: $dropdown-item-padding-y $dropdown-item-padding-x; .user-name { display: block; diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 1cae34ceb9b..5609a2086e6 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -131,7 +131,7 @@ width: 100%; } -.md { +.md:not(.use-csslab) { &.md-preview-holder { // Reset ul style types since we're nested inside a ul already @include bulleted-list; diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index b3b99df5790..0c81dc2e156 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -368,11 +368,11 @@ code { * Apply Markdown typography * */ -.wiki { +.wiki:not(.use-csslab) { @include md-typography; } -.md { +.md:not(.use-csslab) { @include md-typography; } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 8de6294eab4..4449193c104 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -172,6 +172,7 @@ $theme-light-red-700: #a62e21; $black: #000; $black-transparent: rgba(0, 0, 0, 0.3); +$shadow-color: rgba($black, 0.1); $almost-black: #242424; $border-white-light: darken($white-light, $darken-border-factor); @@ -402,7 +403,7 @@ $award-emoji-positive-add-lines: #bb9c13; * Search Box */ $search-input-border-color: rgba($blue-400, 0.8); -$search-input-width: 240px; +$search-input-width: 200px; $search-input-active-width: 320px; $location-icon-color: #e7e9ed; diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss index fab1b361f14..5ca76bb6c5a 100644 --- a/app/assets/stylesheets/framework/variables_overrides.scss +++ b/app/assets/stylesheets/framework/variables_overrides.scss @@ -21,3 +21,10 @@ $danger: $red-500; $zindex-modal-backdrop: 1040; $nav-divider-margin-y: ($grid-size / 2); $dropdown-divider-bg: $theme-gray-200; +$dropdown-item-padding-y: 8px; +$dropdown-item-padding-x: 12px; +$popover-max-width: 300px; +$popover-border-width: 1px; +$popover-border-color: $border-color; +$popover-box-shadow: 0 $border-radius-small $border-radius-default 0 $shadow-color; +$popover-arrow-outer-color: $shadow-color; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 5405f20a760..18c62cb4f1e 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -914,6 +914,7 @@ padding: 0; width: (2px * $image-comment-cursor-left-offset); height: (2px * $image-comment-cursor-top-offset); + color: $blue-400; // center the indicator to match the top left click region margin-top: (-1px * $image-comment-cursor-top-offset) + 2; margin-left: (-1px * $image-comment-cursor-left-offset) + 1; diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 132f3fea92b..a4831b64344 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -98,7 +98,6 @@ // Limits the width of the user bio for readability. max-width: 600px; margin: 10px auto; - padding: 0 16px; } .user-avatar-button { @@ -222,7 +221,11 @@ } .profile-header { - margin: 0 auto; + margin: 0 $gl-padding; + + &.with-no-profile-tabs { + margin-bottom: $gl-padding-24; + } .avatar-holder { width: 90px; diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 04151b1cd59..149c3254d84 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -101,8 +101,6 @@ input[type='checkbox']:hover { .dropdown-header { // Necessary because glDropdown doesn't support a second style of headers font-weight: $gl-font-weight-bold; - // .dropdown-menu li has 1px side padding - padding: $gl-padding-8 17px; color: $gl-text-color; font-size: $gl-font-size; line-height: 16px; diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss index 800f5c68e39..82e887aa62a 100644 --- a/app/assets/stylesheets/pages/wiki.scss +++ b/app/assets/stylesheets/pages/wiki.scss @@ -180,7 +180,7 @@ ul.wiki-pages-list.content-list { } } -.wiki { +.wiki:not(.use-csslab) { table { @include markdown-table; } diff --git a/app/controllers/concerns/renders_commits.rb b/app/controllers/concerns/renders_commits.rb index f48e0586211..ed9b898a2a3 100644 --- a/app/controllers/concerns/renders_commits.rb +++ b/app/controllers/concerns/renders_commits.rb @@ -26,4 +26,10 @@ module RendersCommits commits end + + def valid_ref?(ref_name) + return true unless ref_name.present? + + Gitlab::GitRefValidator.validate(ref_name) + end end diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb index e40a1a1d744..2510a31c9b3 100644 --- a/app/controllers/projects/commits_controller.rb +++ b/app/controllers/projects/commits_controller.rb @@ -11,6 +11,7 @@ class Projects::CommitsController < Projects::ApplicationController before_action :require_non_empty_project before_action :assign_ref_vars, except: :commits_root before_action :authorize_download_code! + before_action :validate_ref!, except: :commits_root before_action :set_commits, except: :commits_root def commits_root @@ -54,6 +55,10 @@ class Projects::CommitsController < Projects::ApplicationController private + def validate_ref! + render_404 unless valid_ref?(@ref) + end + def set_commits render_404 unless @path.empty? || request.format == :atom || @repository.blob_at(@commit.id, @path) || @repository.tree(@commit.id, @path).entries.present? @limit, @offset = (params[:limit] || 40).to_i, (params[:offset] || 0).to_i diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb index 2917925947f..5586c2fc631 100644 --- a/app/controllers/projects/compare_controller.rb +++ b/app/controllers/projects/compare_controller.rb @@ -65,12 +65,6 @@ class Projects::CompareController < Projects::ApplicationController private - def valid_ref?(ref_name) - return true unless ref_name.present? - - Gitlab::GitRefValidator.validate(ref_name) - end - def validate_refs! valid = [head_ref, start_ref].map { |ref| valid_ref?(ref) } diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index e940f382a19..a63eea0ca0e 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -11,6 +11,10 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action :verify_api_request!, only: :terminal_websocket_authorize before_action :expire_etag_cache, only: [:index] + before_action do + push_frontend_feature_flag(:area_chart, project) + end + def index @environments = project.environments .with_state(params[:scope] || :available) diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index 4b6c5b215e8..8d8c62f1291 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -11,6 +11,10 @@ module DropdownsHelper dropdown_output = dropdown_toggle(toggle_text, data_attr, options) + if options.key?(:toggle_link) + dropdown_output = dropdown_toggle_link(toggle_text, data_attr, options) + end + dropdown_output << content_tag(:div, class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.key?(:dropdown_class)}") do output = [] @@ -49,6 +53,11 @@ module DropdownsHelper end end + def dropdown_toggle_link(toggle_text, data_attr, options = {}) + output = content_tag(:a, toggle_text, class: "dropdown-toggle-text #{options[:toggle_class] if options.key?(:toggle_class)}", id: (options[:id] if options.key?(:id)), data: data_attr) + output.html_safe + end + def dropdown_title(title, options: {}) content_tag :div, class: "dropdown-title" do title_output = [] diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index dfa86f52e40..da991458ea7 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -179,7 +179,7 @@ module IssuablesHelper output << "Opened #{time_ago_with_tooltip(issuable.created_at)} by ".html_safe output << content_tag(:strong) do - author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline", tooltip: true) + author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline") author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-block d-sm-none") if status = user_status(issuable.author) diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index a7fe8c3d59c..05da5ebdb22 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -47,8 +47,8 @@ module NavHelper class_names end - def show_separator? - Gitlab::Sherlock.enabled? || can?(current_user, :read_instance_statistics) + def has_extra_nav_icons? + Gitlab::Sherlock.enabled? || can?(current_user, :read_instance_statistics) || current_user.admin? end def page_has_markdown? diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 7ce6b04df7e..87aebe415c8 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -50,6 +50,12 @@ module ProjectsHelper default_opts = { avatar: true, name: true, title: ":name" } opts = default_opts.merge(opts) + data_attrs = { + user_id: author.id, + username: author.username, + name: author.name + } + return "(deleted)" unless author author_html = [] @@ -65,7 +71,7 @@ module ProjectsHelper author_html = author_html.join.html_safe if opts[:name] - link_to(author_html, user_path(author), class: "author-link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}").html_safe + link_to(author_html, user_path(author), class: "author-link js-user-link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}", data: data_attrs).html_safe else title = opts[:title].sub(":name", sanitize(author.name)) link_to(author_html, user_path(author), class: "author-link has-tooltip", title: title, data: { container: 'body' }).html_safe @@ -385,6 +391,10 @@ module ProjectsHelper end end + def sidebar_operations_link_path(project = @project) + metrics_project_environments_path(project) if can?(current_user, :read_environment, project) + end + def project_last_activity(project) if project.last_activity_at time_ago_with_tooltip(project.last_activity_at, placement: 'bottom', html_class: 'last_activity_time_ago') diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb index cf60696ef39..2f802e4eab8 100644 --- a/app/helpers/selects_helper.rb +++ b/app/helpers/selects_helper.rb @@ -29,6 +29,11 @@ module SelectsHelper classes = Array.wrap(opts[:class]) classes << 'ajax-groups-select' + # EE requires this line to be present, but there is no easy way of injecting + # this into EE without causing merge conflicts. Given this line is very + # simple and not really EE specific on its own, we just include it in CE. + classes << 'multiselect' if opts[:multiple] + opts[:class] = classes.join(' ') select2_tag(id, opts) diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb index af699eeebce..498996f4f80 100644 --- a/app/models/concerns/storage/legacy_namespace.rb +++ b/app/models/concerns/storage/legacy_namespace.rb @@ -4,6 +4,8 @@ module Storage module LegacyNamespace extend ActiveSupport::Concern + include Gitlab::ShellAdapter + def move_dir proj_with_tags = first_project_with_container_registry_tags diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 537f2a3a231..016c18ce6c8 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -3,8 +3,6 @@ class ProjectMember < Member SOURCE_TYPE = 'Project'.freeze - include Gitlab::ShellAdapter - belongs_to :project, foreign_key: 'source_id' # Make sure project member points only to project as it source diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 8865c164b11..3c9b1d32a53 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -3,7 +3,6 @@ class Namespace < ActiveRecord::Base include CacheMarkdownField include Sortable - include Gitlab::ShellAdapter include Gitlab::VisibilityLevel include Routable include AfterCommitQueue diff --git a/app/models/project.rb b/app/models/project.rb index 59965cd507e..9e65f7bdbca 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -655,6 +655,11 @@ class Project < ActiveRecord::Base end end + def latest_successful_build_for(job_name, ref = default_branch) + builds = latest_successful_builds_for(ref) + builds.find_by!(name: job_name) + end + def merge_base_commit(first_commit_id, second_commit_id) sha = repository.merge_base(first_commit_id, second_commit_id) commit_by(oid: sha) if sha diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 6c1073265a1..d075440b147 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class ProtectedBranch < ActiveRecord::Base - include Gitlab::ShellAdapter include ProtectedRef protected_ref_access_levels :merge, :push diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb index 94746141945..d28ebabfe49 100644 --- a/app/models/protected_tag.rb +++ b/app/models/protected_tag.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class ProtectedTag < ActiveRecord::Base - include Gitlab::ShellAdapter include ProtectedRef validates :name, uniqueness: { scope: :project_id } diff --git a/app/models/repository.rb b/app/models/repository.rb index a9c167373c3..0ab7e711a01 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -17,7 +17,6 @@ class Repository #{REF_ENVIRONMENTS} ].freeze - include Gitlab::ShellAdapter include Gitlab::RepositoryCacheAdapter attr_accessor :full_path, :disk_path, :project, :is_wiki diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb index 216acf79cbd..5feb0b0f05b 100644 --- a/app/validators/url_validator.rb +++ b/app/validators/url_validator.rb @@ -69,6 +69,7 @@ class UrlValidator < ActiveModel::EachValidator ports: [], allow_localhost: true, allow_local_network: true, + ascii_only: false, enforce_user: false } end diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index ac5916d129c..08a6359f777 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -36,6 +36,7 @@ = stylesheet_link_tag "print", media: "print" = stylesheet_link_tag "test", media: "all" if Rails.env.test? = stylesheet_link_tag 'performance_bar' if performance_bar_enabled? + = stylesheet_link_tag 'csslab' if Feature.enabled?(:csslab) = Gon::Base.render_data diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index b7d69539eb7..e8d0d809181 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -15,7 +15,7 @@ = brand_header_logo - logo_text = brand_header_logo_type - if logo_text.present? - %span.logo-text.d-none.d-sm-block + %span.logo-text.d-none.d-lg-block.prepend-left-8 = logo_text - if current_user diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index ea5f2b166b4..7057a5a142f 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -1,3 +1,5 @@ +-# WAIT! Before adding more items to the nav bar, please see +-# https://gitlab.com/gitlab-org/gitlab-ce/issues/49713 for more information. %ul.list-unstyled.navbar-sub-nav - if dashboard_nav_link?(:projects) = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown" }) do @@ -16,22 +18,22 @@ = render "layouts/nav/groups_dropdown/show" - if dashboard_nav_link?(:activity) - = nav_link(path: 'dashboard#activity', html_options: { class: "d-none d-lg-block d-xl-block" }) do + = nav_link(path: 'dashboard#activity', html_options: { class: ["d-none d-xl-block", ("d-lg-block" unless has_extra_nav_icons?)] }) do = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: _('Activity') do = _('Activity') - if dashboard_nav_link?(:milestones) - = nav_link(controller: 'dashboard/milestones', html_options: { class: "d-none d-lg-block d-xl-block" }) do + = nav_link(controller: 'dashboard/milestones', html_options: { class: ["d-none d-xl-block", ("d-lg-block" unless has_extra_nav_icons?)] }) do = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: _('Milestones') do = _('Milestones') - if dashboard_nav_link?(:snippets) - = nav_link(controller: 'dashboard/snippets', html_options: { class: "d-none d-lg-block d-xl-block" }) do + = nav_link(controller: 'dashboard/snippets', html_options: { class: ["d-none d-xl-block", ("d-lg-block" unless has_extra_nav_icons?)] }) do = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: _('Snippets') do = _('Snippets') - if any_dashboard_nav_link?([:groups, :milestones, :activity, :snippets]) - %li.header-more.dropdown.d-lg-none.d-xl-none + %li.header-more.dropdown.d-xl-none{ class: ('d-lg-none' unless has_extra_nav_icons?) } %a{ href: "#", data: { toggle: "dropdown" } } = _('More') = sprite_icon('angle-down', css_class: 'caret-down') @@ -52,6 +54,21 @@ = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: _('Snippets') do = _('Snippets') + = render_if_exists 'dashboard/operations/nav_link' + - if can?(current_user, :read_instance_statistics) + = nav_link(controller: [:conversational_development_index, :cohorts]) do + = link_to instance_statistics_root_path, title: _('Instance Statistics'), aria: { label: _('Instance Statistics') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = _('Instance Statistics') + - if current_user.admin? + = nav_link(controller: 'admin/dashboard') do + = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin Area'), aria: { label: _('Admin Area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = _('Admin Area') + - if Gitlab::Sherlock.enabled? + %li + = link_to sherlock_transactions_path, class: 'admin-icon', title: _('Sherlock Transactions'), + data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = _('Sherlock Transactions') + -# Shortcut to Dashboard > Projects - if dashboard_nav_link?(:projects) %li.hidden @@ -64,19 +81,17 @@ = link_to '#', class: 'dashboard-shortcuts-web-ide', title: _('Web IDE') do = _('Web IDE') - - if show_separator? - %li.line-separator.d-none.d-sm-block = render_if_exists 'dashboard/operations/nav_link' - if can?(current_user, :read_instance_statistics) - = nav_link(controller: [:conversational_development_index, :cohorts]) do + = nav_link(controller: [:conversational_development_index, :cohorts], html_options: { class: "d-none d-lg-block d-xl-block"}) do = link_to instance_statistics_root_path, title: _('Instance Statistics'), aria: { label: _('Instance Statistics') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = sprite_icon('chart', size: 18) - if current_user.admin? - = nav_link(controller: 'admin/dashboard') do - = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin area'), aria: { label: _('Admin area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = nav_link(controller: 'admin/dashboard', html_options: { class: "d-none d-lg-block d-xl-block"}) do + = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin Area'), aria: { label: _('Admin Area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = sprite_icon('admin', size: 18) - if Gitlab::Sherlock.enabled? %li - = link_to sherlock_transactions_path, class: 'admin-icon', title: _('Sherlock Transactions'), + = link_to sherlock_transactions_path, class: 'admin-icon d-none d-lg-block d-xl-block', title: _('Sherlock Transactions'), data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('tachometer fw') diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index b89541a3c9f..bdd0108db0d 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -196,7 +196,7 @@ - if project_nav_tab? :operations = nav_link(controller: sidebar_operations_paths) do - = link_to metrics_project_environments_path(@project), class: 'shortcuts-operations' do + = link_to sidebar_operations_link_path, class: 'shortcuts-operations' do .nav-icon-container = sprite_icon('cloud-gear') %span.nav-item-name @@ -204,7 +204,7 @@ %ul.sidebar-sub-level-items = nav_link(controller: sidebar_operations_paths, html_options: { class: "fly-out-top-item" } ) do - = link_to metrics_project_environments_path(@project) do + = link_to sidebar_operations_link_path do %strong.fly-out-top-item-name = _('Operations') %li.divider.fly-out-top-item diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index cf273aab108..95c5eb32c7f 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -9,6 +9,6 @@ = render "projects/blob/auxiliary_viewer", blob: blob #blob-content-holder.blob-content-holder - %article.file-holder + %article.file-holder{ class: ('use-csslab' if Feature.enabled?(:csslab)) } = render 'projects/blob/header', blob: blob = render 'projects/blob/content', blob: blob diff --git a/app/views/projects/blob/preview.html.haml b/app/views/projects/blob/preview.html.haml index eb65cd90ea8..ff460a3831c 100644 --- a/app/views/projects/blob/preview.html.haml +++ b/app/views/projects/blob/preview.html.haml @@ -1,7 +1,7 @@ .diff-file.file-holder .diff-content - if markup?(@blob.name) - .file-content.wiki + .file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) } = markup(@blob.name, @content, legacy_render_context(params)) - else .file-content.code.js-syntax-highlight diff --git a/app/views/projects/blob/viewers/_markup.html.haml b/app/views/projects/blob/viewers/_markup.html.haml index bd12cadf240..6edbfd91b21 100644 --- a/app/views/projects/blob/viewers/_markup.html.haml +++ b/app/views/projects/blob/viewers/_markup.html.haml @@ -2,5 +2,5 @@ - context = legacy_render_context(params) - unless context[:markdown_engine] == :redcarpet - context[:rendered] = blob.rendered_markup if blob.respond_to?(:rendered_markup) -.file-content.wiki +.file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) } = markup(blob.name, blob.data, context) diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml index f495b4eaf30..da48cb207a4 100644 --- a/app/views/projects/snippets/show.html.haml +++ b/app/views/projects/snippets/show.html.haml @@ -6,7 +6,7 @@ = render 'shared/snippets/header' .project-snippets - %article.file-holder.snippet-file-content + %article.file-holder.snippet-file-content{ class: ('use-csslab' if Feature.enabled?(:csslab)) } = render 'shared/snippets/blob' .row-content-block.top-block.content-component-block diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml index cc38ec12fd8..4d5fd55364c 100644 --- a/app/views/projects/wikis/show.html.haml +++ b/app/views/projects/wikis/show.html.haml @@ -26,7 +26,7 @@ = (s_("WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}.") % { most_recent_link: most_recent_link, history_link: history_link }).html_safe .prepend-top-default.append-bottom-default - .wiki + .wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) } = render_wiki_content(@page, legacy_render_context(params)) = render 'sidebar' diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 5295e656ab0..9eecfa39390 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -16,7 +16,7 @@ - if current_user .block.todo.hide-expanded = render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable, is_collapsed: true - .block.assignee + .block.assignee.qa-assignee-block = render "shared/issuable/sidebar_assignees", issuable: issuable, can_edit_issuable: can_edit_issuable, signed_in: current_user.present? = render_if_exists 'shared/issuable/sidebar_item_epic', issuable: issuable diff --git a/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml index 3521f71f409..60c34094108 100644 --- a/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml +++ b/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml @@ -5,4 +5,4 @@ = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} }) - = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}" + = link_to 'Assign to me', '#', class: "assign-to-me-link qa-assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}" diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index d11476738e4..dd2cd36eac2 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -31,12 +31,12 @@ data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('users') - .profile-header + .profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?)] } .avatar-holder = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do = image_tag avatar_icon_for_user(@user, 90), class: "avatar s90", alt: '' - .user-info.prepend-left-default.append-right-default + .user-info .cover-title = @user.name @@ -81,10 +81,10 @@ = icon('briefcase') = @user.organization - - if @user.bio.present? - .cover-desc - %p.profile-user-bio - = @user.bio + - if @user.bio.present? + .cover-desc + %p.profile-user-bio + = @user.bio - unless profile_tabs.empty? .scrolling-tabs-container diff --git a/app/workers/repository_update_remote_mirror_worker.rb b/app/workers/repository_update_remote_mirror_worker.rb index 9d4e67deb9c..bd429d526bf 100644 --- a/app/workers/repository_update_remote_mirror_worker.rb +++ b/app/workers/repository_update_remote_mirror_worker.rb @@ -5,7 +5,6 @@ class RepositoryUpdateRemoteMirrorWorker UpdateError = Class.new(StandardError) include ApplicationWorker - include Gitlab::ShellAdapter sidekiq_options retry: 3, dead: false diff --git a/changelogs/unreleased/49713-main-navbar-is-broken-in-certain-viewport-widths.yml b/changelogs/unreleased/49713-main-navbar-is-broken-in-certain-viewport-widths.yml new file mode 100644 index 00000000000..0b5d1a6b05a --- /dev/null +++ b/changelogs/unreleased/49713-main-navbar-is-broken-in-certain-viewport-widths.yml @@ -0,0 +1,5 @@ +--- +title: Resolve Main navbar is broken in certain viewport widths +merge_request: 23348 +author: +type: fixed diff --git a/changelogs/unreleased/50157-extended-user-centric-tooltips.yml b/changelogs/unreleased/50157-extended-user-centric-tooltips.yml new file mode 100644 index 00000000000..3b55a867b87 --- /dev/null +++ b/changelogs/unreleased/50157-extended-user-centric-tooltips.yml @@ -0,0 +1,5 @@ +--- +title: Extended user centric tooltips on issue and MR page +merge_request: 23231 +author: +type: added diff --git a/changelogs/unreleased/51122-fix-navigating-discussions.yml b/changelogs/unreleased/51122-fix-navigating-discussions.yml new file mode 100644 index 00000000000..94d76654589 --- /dev/null +++ b/changelogs/unreleased/51122-fix-navigating-discussions.yml @@ -0,0 +1,5 @@ +--- +title: Fix navigating by unresolved discussions on Merge Request page +merge_request: 22789 +author: +type: fixed diff --git a/changelogs/unreleased/54626-able-to-download-a-single-archive-file-with-api-by-ref-name.yml b/changelogs/unreleased/54626-able-to-download-a-single-archive-file-with-api-by-ref-name.yml new file mode 100644 index 00000000000..fa905b47ca2 --- /dev/null +++ b/changelogs/unreleased/54626-able-to-download-a-single-archive-file-with-api-by-ref-name.yml @@ -0,0 +1,5 @@ +--- +title: Add new endpoint to download single artifact file for a ref +merge_request: 23538 +author: +type: added diff --git a/changelogs/unreleased/55402-broken-master-karma-test-failing-in-spec-javascripts-boards-components-issue_due_date_spec-js.yml b/changelogs/unreleased/55402-broken-master-karma-test-failing-in-spec-javascripts-boards-components-issue_due_date_spec-js.yml new file mode 100644 index 00000000000..d2ff095ce55 --- /dev/null +++ b/changelogs/unreleased/55402-broken-master-karma-test-failing-in-spec-javascripts-boards-components-issue_due_date_spec-js.yml @@ -0,0 +1,5 @@ +--- +title: Fix due date test +merge_request: 23845 +author: +type: other diff --git a/changelogs/unreleased/commit-badge-style-fix.yml b/changelogs/unreleased/commit-badge-style-fix.yml new file mode 100644 index 00000000000..d7b37717853 --- /dev/null +++ b/changelogs/unreleased/commit-badge-style-fix.yml @@ -0,0 +1,5 @@ +--- +title: Fixed styling of image comment badges on commits +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/osw-update-mr-metrics-with-events-data.yml b/changelogs/unreleased/osw-update-mr-metrics-with-events-data.yml new file mode 100644 index 00000000000..09a10a86adc --- /dev/null +++ b/changelogs/unreleased/osw-update-mr-metrics-with-events-data.yml @@ -0,0 +1,5 @@ +--- +title: Populate MR metrics with events table information (migration) +merge_request: 23564 +author: +type: performance diff --git a/changelogs/unreleased/profile-fixing.yml b/changelogs/unreleased/profile-fixing.yml new file mode 100644 index 00000000000..7e255d997d8 --- /dev/null +++ b/changelogs/unreleased/profile-fixing.yml @@ -0,0 +1,5 @@ +--- +title: Fix bottom paddings of profile header and some markup updates of profile +merge_request: 23168 +author: Harry Kiselev +type: other diff --git a/changelogs/unreleased/upgrade-to-workhorse-7-6-0.yml b/changelogs/unreleased/upgrade-to-workhorse-7-6-0.yml new file mode 100644 index 00000000000..1389693b9a9 --- /dev/null +++ b/changelogs/unreleased/upgrade-to-workhorse-7-6-0.yml @@ -0,0 +1,5 @@ +--- +title: Upgrade workhorse to 7.6.0 +merge_request: 23694 +author: +type: other diff --git a/changelogs/unreleased/winh-dropdown-item-padding.yml b/changelogs/unreleased/winh-dropdown-item-padding.yml new file mode 100644 index 00000000000..9f18abba9d1 --- /dev/null +++ b/changelogs/unreleased/winh-dropdown-item-padding.yml @@ -0,0 +1,5 @@ +--- +title: Adjust dropdown item and header padding to comply with design specs +merge_request: 23552 +author: +type: changed diff --git a/config/application.rb b/config/application.rb index 63a5b483fc2..f10b8ed5bd2 100644 --- a/config/application.rb +++ b/config/application.rb @@ -154,6 +154,7 @@ module Gitlab config.assets.precompile << "locale/**/app.js" config.assets.precompile << "emoji_sprites.css" config.assets.precompile << "errors.css" + config.assets.precompile << "csslab.css" # Import gitlab-svgs directly from vendored directory config.assets.paths << "#{config.root}/node_modules/@gitlab/svgs/dist" diff --git a/config/dependency_decisions.yml b/config/dependency_decisions.yml index 6e4f7ce30a0..af76bace577 100644 --- a/config/dependency_decisions.yml +++ b/config/dependency_decisions.yml @@ -592,9 +592,10 @@ in compiled/distributed product so attribution not needed. :versions: [] :when: 2018-10-02 19:23:54.840151000 Z -- - :approve +- - :license - echarts - - :who: Mike Greiling + - Apache 2.0 + - :who: Adriel Santiago :why: https://github.com/apache/incubator-echarts/blob/master/LICENSE :versions: [] - :when: 2018-12-05 22:12:30.550027000 Z + :when: 2018-12-07 20:46:12.421256000 Z diff --git a/db/post_migrate/20161221153951_rename_reserved_project_names.rb b/db/post_migrate/20161221153951_rename_reserved_project_names.rb index b7665e98490..50e1c8449ba 100644 --- a/db/post_migrate/20161221153951_rename_reserved_project_names.rb +++ b/db/post_migrate/20161221153951_rename_reserved_project_names.rb @@ -1,6 +1,5 @@ class RenameReservedProjectNames < ActiveRecord::Migration[4.2] include Gitlab::Database::MigrationHelpers - include Gitlab::ShellAdapter DOWNTIME = false diff --git a/db/post_migrate/20170313133418_rename_more_reserved_project_names.rb b/db/post_migrate/20170313133418_rename_more_reserved_project_names.rb index cac3fd713eb..bef669b459d 100644 --- a/db/post_migrate/20170313133418_rename_more_reserved_project_names.rb +++ b/db/post_migrate/20170313133418_rename_more_reserved_project_names.rb @@ -1,6 +1,5 @@ class RenameMoreReservedProjectNames < ActiveRecord::Migration[4.2] include Gitlab::Database::MigrationHelpers - include Gitlab::ShellAdapter DOWNTIME = false diff --git a/db/post_migrate/20181204154019_populate_mr_metrics_with_events_data.rb b/db/post_migrate/20181204154019_populate_mr_metrics_with_events_data.rb new file mode 100644 index 00000000000..1e43e3dd790 --- /dev/null +++ b/db/post_migrate/20181204154019_populate_mr_metrics_with_events_data.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class PopulateMrMetricsWithEventsData < ActiveRecord::Migration[4.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + BATCH_SIZE = 10_000 + MIGRATION = 'PopulateMergeRequestMetricsWithEventsDataImproved' + PREVIOUS_MIGRATION = 'PopulateMergeRequestMetricsWithEventsData' + + disable_ddl_transaction! + + def up + # Perform any ongoing background migration that might still be running from + # previous try (see https://gitlab.com/gitlab-org/gitlab-ce/issues/47676). + Gitlab::BackgroundMigration.steal(PREVIOUS_MIGRATION) + + say 'Scheduling `PopulateMergeRequestMetricsWithEventsData` jobs' + # It will update around 4_000_000 records in batches of 10_000 merge + # requests (running between 5 minutes) and should take around 53 hours to complete. + # Apparently, production PostgreSQL is able to vacuum 10k-20k dead_tuples + # per minute. So this should give us enough space. + # + # More information about the updates in `PopulateMergeRequestMetricsWithEventsDataImproved` class. + # + MergeRequest.all.each_batch(of: BATCH_SIZE) do |relation, index| + range = relation.pluck('MIN(id)', 'MAX(id)').first + + BackgroundMigrationWorker.perform_in(index * 8.minutes, MIGRATION, range) + end + end + + def down + end +end diff --git a/db/schema.rb b/db/schema.rb index ebc63488835..10b7aa8a99f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20181203002526) do +ActiveRecord::Schema.define(version: 20181204154019) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" diff --git a/doc/api/jobs.md b/doc/api/jobs.md index aa290ff4cf8..589c48ee08d 100644 --- a/doc/api/jobs.md +++ b/doc/api/jobs.md @@ -404,7 +404,7 @@ Example response: [ce-5347]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5347 -## Download a single artifact file +## Download a single artifact file by job ID > Introduced in GitLab 10.0 @@ -438,6 +438,41 @@ Example response: | 400 | Invalid path provided | | 404 | Build not found or no file/artifacts | +## Download a single artifact file from specific tag or branch + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/23538) in GitLab 11.5. + +Download a single artifact file from a specific tag or branch from within the +job's artifacts archive. The file is extracted from the archive and streamed to +the client. + +``` +GET /projects/:id/jobs/artifacts/:ref_name/raw/*artifact_path?job=name +``` + +Parameters: + +| Attribute | Type | Required | Description | +|-----------------|----------------|----------|------------------------------------------------------------------------------------------------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user. | +| `ref_name` | string | yes | Branch or tag name in repository. HEAD or SHA references are not supported. | +| `artifact_path` | string | yes | Path to a file inside the artifacts archive. | +| `job` | string | yes | The name of the job. | + +Example request: + +```sh +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/jobs/artifacts/master/raw/some/release/file.pdf?job=pdf" +``` + +Possible response status codes: + +| Status | Description | +|-----------|--------------------------------------| +| 200 | Sends a single artifact file | +| 400 | Invalid path provided | +| 404 | Build not found or no file/artifacts | + ## Get a trace file Get a trace of a specific job of a project diff --git a/lib/api/job_artifacts.rb b/lib/api/job_artifacts.rb index 7c2d8ff11bf..a4068a200b3 100644 --- a/lib/api/job_artifacts.rb +++ b/lib/api/job_artifacts.rb @@ -35,6 +35,29 @@ module API end # rubocop: enable CodeReuse/ActiveRecord + desc 'Download a specific file from artifacts archive from a ref' do + detail 'This feature was introduced in GitLab 11.5' + end + params do + requires :ref_name, type: String, desc: 'The ref from repository' + requires :job, type: String, desc: 'The name for the job' + requires :artifact_path, type: String, desc: 'Artifact path' + end + get ':id/jobs/artifacts/:ref_name/raw/*artifact_path', + format: false, + requirements: { ref_name: /.+/ } do + authorize_download_artifacts! + + build = user_project.latest_successful_build_for(params[:job], params[:ref_name]) + + path = Gitlab::Ci::Build::Artifacts::Path + .new(params[:artifact_path]) + + bad_request! unless path.valid? + + send_artifacts_entry(build, path) + end + desc 'Download the artifacts archive from a job' do detail 'This feature was introduced in GitLab 8.5' end @@ -65,6 +88,7 @@ module API path = Gitlab::Ci::Build::Artifacts::Path .new(params[:artifact_path]) + bad_request! unless path.valid? send_artifacts_entry(build, path) diff --git a/lib/banzai/filter/user_reference_filter.rb b/lib/banzai/filter/user_reference_filter.rb index 11960047e5b..8cda67867a8 100644 --- a/lib/banzai/filter/user_reference_filter.rb +++ b/lib/banzai/filter/user_reference_filter.rb @@ -106,7 +106,7 @@ module Banzai end def link_class - reference_class(:project_member) + reference_class(:project_member, tooltip: false) end def link_to_all(link_content: nil) diff --git a/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_improved.rb b/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_improved.rb new file mode 100644 index 00000000000..37592d67dd9 --- /dev/null +++ b/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_improved.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true +# rubocop:disable Style/Documentation + +module Gitlab + module BackgroundMigration + class PopulateMergeRequestMetricsWithEventsDataImproved + CLOSED_EVENT_ACTION = 3 + MERGED_EVENT_ACTION = 7 + + def perform(min_merge_request_id, max_merge_request_id) + insert_metrics_for_range(min_merge_request_id, max_merge_request_id) + update_metrics_with_events_data(min_merge_request_id, max_merge_request_id) + end + + # Inserts merge_request_metrics records for merge_requests without it for + # a given merge request batch. + def insert_metrics_for_range(min, max) + metrics_not_exists_clause = + <<-SQL.strip_heredoc + NOT EXISTS (SELECT 1 FROM merge_request_metrics + WHERE merge_request_metrics.merge_request_id = merge_requests.id) + SQL + + MergeRequest.where(metrics_not_exists_clause).where(id: min..max).each_batch do |batch| + select_sql = batch.select(:id, :created_at, :updated_at).to_sql + + execute("INSERT INTO merge_request_metrics (merge_request_id, created_at, updated_at) #{select_sql}") + end + end + + def update_metrics_with_events_data(min, max) + if Gitlab::Database.postgresql? + psql_update_metrics_with_events_data(min, max) + else + mysql_update_metrics_with_events_data(min, max) + end + end + + def psql_update_metrics_with_events_data(min, max) + update_sql = <<-SQL.strip_heredoc + UPDATE merge_request_metrics + SET (latest_closed_at, + latest_closed_by_id) = + ( SELECT updated_at, + author_id + FROM events + WHERE target_id = merge_request_id + AND target_type = 'MergeRequest' + AND action = #{CLOSED_EVENT_ACTION} + ORDER BY id DESC + LIMIT 1 ), + merged_by_id = + ( SELECT author_id + FROM events + WHERE target_id = merge_request_id + AND target_type = 'MergeRequest' + AND action = #{MERGED_EVENT_ACTION} + ORDER BY id DESC + LIMIT 1 ) + WHERE merge_request_id BETWEEN #{min} AND #{max} + SQL + + execute(update_sql) + end + + def mysql_update_metrics_with_events_data(min, max) + closed_updated_at_subquery = mysql_events_select(:updated_at, CLOSED_EVENT_ACTION) + closed_author_id_subquery = mysql_events_select(:author_id, CLOSED_EVENT_ACTION) + merged_author_id_subquery = mysql_events_select(:author_id, MERGED_EVENT_ACTION) + + update_sql = <<-SQL.strip_heredoc + UPDATE merge_request_metrics + SET latest_closed_at = (#{closed_updated_at_subquery}), + latest_closed_by_id = (#{closed_author_id_subquery}), + merged_by_id = (#{merged_author_id_subquery}) + WHERE merge_request_id BETWEEN #{min} AND #{max} + SQL + + execute(update_sql) + end + + def mysql_events_select(column, action) + <<-SQL.strip_heredoc + SELECT #{column} FROM events + WHERE target_id = merge_request_id + AND target_type = 'MergeRequest' + AND action = #{action} + ORDER BY id DESC + LIMIT 1 + SQL + end + + def execute(sql) + @connection ||= ActiveRecord::Base.connection + @connection.execute(sql) + end + end + end +end diff --git a/lib/gitlab/bitbucket_server_import/importer.rb b/lib/gitlab/bitbucket_server_import/importer.rb index d4080536d81..28cfb46e2d4 100644 --- a/lib/gitlab/bitbucket_server_import/importer.rb +++ b/lib/gitlab/bitbucket_server_import/importer.rb @@ -3,8 +3,6 @@ module Gitlab module BitbucketServerImport class Importer - include Gitlab::ShellAdapter - attr_reader :recover_missing_commits attr_reader :project, :project_key, :repository_slug, :client, :errors, :users attr_accessor :logger diff --git a/lib/gitlab/ci/config/entry/except_policy.rb b/lib/gitlab/ci/config/entry/except_policy.rb new file mode 100644 index 00000000000..46ded35325d --- /dev/null +++ b/lib/gitlab/ci/config/entry/except_policy.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + ## + # Entry that represents an only/except trigger policy for the job. + # + class ExceptPolicy < Policy + def self.default + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb index 50942fbdb40..22400798e9e 100644 --- a/lib/gitlab/ci/config/entry/job.rb +++ b/lib/gitlab/ci/config/entry/job.rb @@ -72,10 +72,10 @@ module Gitlab entry :services, Entry::Services, description: 'Services that will be used to execute this job.' - entry :only, Entry::Policy, + entry :only, Entry::OnlyPolicy, description: 'Refs policy this job will be executed for.' - entry :except, Entry::Policy, + entry :except, Entry::ExceptPolicy, description: 'Refs policy this job will be executed for.' entry :variables, Entry::Variables, diff --git a/lib/gitlab/ci/config/entry/only_policy.rb b/lib/gitlab/ci/config/entry/only_policy.rb new file mode 100644 index 00000000000..9a581b8e97e --- /dev/null +++ b/lib/gitlab/ci/config/entry/only_policy.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + ## + # Entry that represents an only/except trigger policy for the job. + # + class OnlyPolicy < Policy + def self.default + { refs: %w[branches tags] } + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/entry/policy.rb b/lib/gitlab/ci/config/entry/policy.rb index 998da1f6837..d98b60f07d6 100644 --- a/lib/gitlab/ci/config/entry/policy.rb +++ b/lib/gitlab/ci/config/entry/policy.rb @@ -5,7 +5,7 @@ module Gitlab class Config module Entry ## - # Entry that represents an only/except trigger policy for the job. + # Base class for OnlyPolicy and ExceptPolicy # class Policy < ::Gitlab::Config::Entry::Simplifiable strategy :RefsPolicy, if: -> (config) { config.is_a?(Array) } @@ -66,6 +66,16 @@ module Gitlab def self.default end + + ## + # Class-level execution won't be inherited by subclasses by default. + # Therefore, we need to explicitly execute that for OnlyPolicy and ExceptPolicy + def self.inherited(klass) + super + + klass.strategy :RefsPolicy, if: -> (config) { config.is_a?(Array) } + klass.strategy :ComplexPolicy, if: -> (config) { config.is_a?(Hash) } + end end end end diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb index 255601382b1..11021ee06b3 100644 --- a/lib/gitlab/gitaly_client.rb +++ b/lib/gitlab/gitaly_client.rb @@ -193,7 +193,7 @@ module Gitlab feature = feature_stack && feature_stack[0] metadata['call_site'] = feature.to_s if feature metadata['gitaly-servers'] = address_metadata(remote_storage) if remote_storage - metadata['correlation_id'] = Gitlab::CorrelationId.current_id if Gitlab::CorrelationId.current_id + metadata['x-gitlab-correlation-id'] = Gitlab::CorrelationId.current_id if Gitlab::CorrelationId.current_id metadata.merge!(server_feature_flags) diff --git a/lib/gitlab/import_export/repo_restorer.rb b/lib/gitlab/import_export/repo_restorer.rb index 921a06b4023..91167a9c4fb 100644 --- a/lib/gitlab/import_export/repo_restorer.rb +++ b/lib/gitlab/import_export/repo_restorer.rb @@ -4,7 +4,6 @@ module Gitlab module ImportExport class RepoRestorer include Gitlab::ImportExport::CommandLineUtil - include Gitlab::ShellAdapter def initialize(project:, shared:, path_to_bundle:) @project = project diff --git a/lib/gitlab/url_blocker.rb b/lib/gitlab/url_blocker.rb index b8040f73cee..44c71f8431d 100644 --- a/lib/gitlab/url_blocker.rb +++ b/lib/gitlab/url_blocker.rb @@ -8,7 +8,7 @@ module Gitlab BlockedUrlError = Class.new(StandardError) class << self - def validate!(url, allow_localhost: false, allow_local_network: true, enforce_user: false, ports: [], protocols: []) + def validate!(url, ports: [], protocols: [], allow_localhost: false, allow_local_network: true, ascii_only: false, enforce_user: false) return true if url.nil? # Param url can be a string, URI or Addressable::URI @@ -22,6 +22,7 @@ module Gitlab validate_port!(port, ports) if ports.any? validate_user!(uri.user) if enforce_user validate_hostname!(uri.hostname) + validate_unicode_restriction!(uri) if ascii_only begin addrs_info = Addrinfo.getaddrinfo(uri.hostname, port, nil, :STREAM).map do |addr| @@ -91,6 +92,12 @@ module Gitlab raise BlockedUrlError, "Hostname or IP address invalid" end + def validate_unicode_restriction!(uri) + return if uri.to_s.ascii_only? + + raise BlockedUrlError, "URI must be ascii only #{uri.to_s.dump}" + end + def validate_localhost!(addrs_info) local_ips = ["::", "0.0.0.0"] local_ips.concat(Socket.ip_address_list.map(&:ip_address)) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 2534c67c335..48a8bb391f5 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -97,6 +97,9 @@ msgstr[1] "" msgid "%{actionText} & %{openOrClose} %{noteable}" msgstr "" +msgid "%{bio} at %{organization}" +msgstr "" + msgid "%{commit_author_link} authored %{commit_timeago}" msgstr "" @@ -402,9 +405,6 @@ msgstr "" msgid "Admin Overview" msgstr "" -msgid "Admin area" -msgstr "" - msgid "AdminArea| You are about to permanently delete the user %{username}. Issues, merge requests, and groups linked to them will be transferred to a system-wide \"Ghost-user\". To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead. Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered." msgstr "" @@ -852,6 +852,9 @@ msgstr "" msgid "Available specific runners" msgstr "" +msgid "Avatar for %{assigneeName}" +msgstr "" + msgid "Avatar will be removed. Are you sure?" msgstr "" @@ -2923,6 +2926,9 @@ msgstr "" msgid "Expiration date" msgstr "" +msgid "Expired %{expiredOn}" +msgstr "" + msgid "Expires in %{expires_at}" msgstr "" @@ -6287,6 +6293,12 @@ msgstr "" msgid "Started" msgstr "" +msgid "Started %{startsIn}" +msgstr "" + +msgid "Starts %{startsIn}" +msgstr "" + msgid "Starts at (UTC)" msgstr "" diff --git a/package.json b/package.json index 60882f0dad8..d546a32cba5 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "diff": "^3.4.0", "document-register-element": "1.3.0", "dropzone": "^4.2.0", + "echarts": "^4.2.0-rc.2", "emoji-unicode-version": "^0.2.1", "exports-loader": "^0.7.0", "file-loader": "^2.0.0", diff --git a/qa/qa/page/merge_request/new.rb b/qa/qa/page/merge_request/new.rb index 1f8f1fbca8e..20d9c336367 100644 --- a/qa/qa/page/merge_request/new.rb +++ b/qa/qa/page/merge_request/new.rb @@ -26,6 +26,10 @@ module QA element :issuable_label end + view 'app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml' do + element :assign_to_me_link + end + def create_merge_request click_element :issuable_create_button end @@ -50,6 +54,10 @@ module QA click_link label.title end + + def assign_to_me + click_element :assign_to_me_link + end end end end diff --git a/qa/qa/page/merge_request/show.rb b/qa/qa/page/merge_request/show.rb index 2fd30e15ffb..869dc0b9d21 100644 --- a/qa/qa/page/merge_request/show.rb +++ b/qa/qa/page/merge_request/show.rb @@ -52,6 +52,7 @@ module QA end view 'app/views/shared/issuable/_sidebar.html.haml' do + element :assignee_block element :labels_block end @@ -100,6 +101,12 @@ module QA end end + def has_assignee?(username) + page.within(element_selector_css(:assignee_block)) do + has_text?(username) + end + end + def has_label?(label) page.within(element_selector_css(:labels_block)) do element = find('span', text: label) diff --git a/qa/qa/resource/merge_request.rb b/qa/qa/resource/merge_request.rb index cdfcf2b8742..7150098a00a 100644 --- a/qa/qa/resource/merge_request.rb +++ b/qa/qa/resource/merge_request.rb @@ -66,6 +66,7 @@ module QA page.fill_title(@title) page.fill_description(@description) page.choose_milestone(@milestone) if @milestone + page.assign_to_me if @assignee == 'me' labels.each do |label| page.select_label(label) end diff --git a/qa/qa/runtime/browser.rb b/qa/qa/runtime/browser.rb index 7fd2ba25527..b706d6565d2 100644 --- a/qa/qa/runtime/browser.rb +++ b/qa/qa/runtime/browser.rb @@ -70,6 +70,13 @@ module QA options.add_argument("disable-gpu") end + # Use the same profile on QA runs if CHROME_REUSE_PROFILE is true. + # Useful to speed up local QA. + if QA::Runtime::Env.reuse_chrome_profile? + qa_profile_dir = ::File.expand_path('../../tmp/qa-profile', __dir__) + options.add_argument("user-data-dir=#{qa_profile_dir}") + end + # Disable /dev/shm use in CI. See https://gitlab.com/gitlab-org/gitlab-ee/issues/4252 options.add_argument("disable-dev-shm-usage") if QA::Runtime::Env.running_in_ci? diff --git a/qa/qa/runtime/env.rb b/qa/qa/runtime/env.rb index 3bc2b44ccd8..dae5aa3f794 100644 --- a/qa/qa/runtime/env.rb +++ b/qa/qa/runtime/env.rb @@ -30,6 +30,11 @@ module QA enabled?(ENV['CHROME_HEADLESS']) end + # set to 'true' to have Chrome use a fixed profile directory + def reuse_chrome_profile? + enabled?(ENV['CHROME_REUSE_PROFILE'], default: false) + end + def accept_insecure_certs? enabled?(ENV['ACCEPT_INSECURE_CERTS']) end diff --git a/qa/qa/specs/features/browser_ui/3_create/merge_request/create_merge_request_spec.rb b/qa/qa/specs/features/browser_ui/3_create/merge_request/create_merge_request_spec.rb index d33947f41da..6ddd7dde2cf 100644 --- a/qa/qa/specs/features/browser_ui/3_create/merge_request/create_merge_request_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/merge_request/create_merge_request_spec.rb @@ -4,6 +4,8 @@ module QA context 'Create' do describe 'Merge request creation' do it 'user creates a new merge request' do + gitlab_account_username = "@#{Runtime::User.username}" + Runtime::Browser.visit(:gitlab, Page::Main::Login) Page::Main::Login.act { sign_in_using_credentials } @@ -27,6 +29,7 @@ module QA merge_request.description = 'Great feature with milestone' merge_request.project = current_project merge_request.milestone = current_milestone + merge_request.assignee = 'me' merge_request.labels.push(new_label) end @@ -34,6 +37,7 @@ module QA expect(merge_request).to have_content('This is a merge request with a milestone') expect(merge_request).to have_content('Great feature with milestone') expect(merge_request).to have_content(/Opened [\w\s]+ ago/) + expect(merge_request).to have_assignee(gitlab_account_username) expect(merge_request).to have_label(new_label.title) end diff --git a/spec/controllers/projects/commits_controller_spec.rb b/spec/controllers/projects/commits_controller_spec.rb index 5c72dab698c..80513650636 100644 --- a/spec/controllers/projects/commits_controller_spec.rb +++ b/spec/controllers/projects/commits_controller_spec.rb @@ -53,6 +53,12 @@ describe Projects::CommitsController do it { is_expected.to respond_with(:not_found) } end + + context "branch with invalid format, valid file" do + let(:id) { 'branch with space/README.md' } + + it { is_expected.to respond_with(:not_found) } + end end context "when the ref name ends in .atom" do @@ -94,6 +100,30 @@ describe Projects::CommitsController do end end end + + describe "GET /commits/:id/signatures" do + render_views + + before do + get(:signatures, + namespace_id: project.namespace, + project_id: project, + id: id, + format: :json) + end + + context "valid branch" do + let(:id) { 'master' } + + it { is_expected.to respond_with(:success) } + end + + context "invalid branch format" do + let(:id) { 'some branch' } + + it { is_expected.to respond_with(:not_found) } + end + end end context 'token authentication' do diff --git a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb index 328f96e6ed7..ba4806821f9 100644 --- a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb +++ b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb @@ -361,8 +361,14 @@ describe 'Merge request > User resolves diff notes and discussions', :js do end end - it 'shows jump to next discussion button' do - expect(page.all('.discussion-reply-holder', count: 2)).to all(have_selector('.discussion-next-btn')) + it 'shows jump to next discussion button except on last discussion' do + wait_for_requests + + all_discussion_replies = page.all('.discussion-reply-holder') + + expect(all_discussion_replies.count).to eq(2) + expect(all_discussion_replies.first.all('.discussion-next-btn').count).to eq(1) + expect(all_discussion_replies.last.all('.discussion-next-btn').count).to eq(0) end it 'displays next discussion even if hidden' do @@ -380,7 +386,13 @@ describe 'Merge request > User resolves diff notes and discussions', :js do page.find('.discussion-next-btn').click end - expect(find('.discussion-with-resolve-btn')).to have_selector('.btn', text: 'Resolve discussion') + page.all('.note-discussion').first do + expect(page.find('.discussion-with-resolve-btn')).to have_selector('.btn', text: 'Resolve discussion') + end + + page.all('.note-discussion').last do + expect(page.find('.discussion-with-resolve-btn')).not.to have_selector('.btn', text: 'Resolve discussion') + end end end diff --git a/spec/features/projects/files/user_browses_files_spec.rb b/spec/features/projects/files/user_browses_files_spec.rb index f3cf3a282e5..66268355345 100644 --- a/spec/features/projects/files/user_browses_files_spec.rb +++ b/spec/features/projects/files/user_browses_files_spec.rb @@ -11,6 +11,7 @@ describe "User browses files" do let(:user) { project.owner } before do + stub_feature_flags(csslab: false) sign_in(user) end diff --git a/spec/javascripts/api_spec.js b/spec/javascripts/api_spec.js index 46f72214831..9d55c615450 100644 --- a/spec/javascripts/api_spec.js +++ b/spec/javascripts/api_spec.js @@ -333,6 +333,40 @@ describe('Api', () => { }); }); + describe('user', () => { + it('fetches single user', done => { + const userId = '123456'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}`; + mock.onGet(expectedUrl).reply(200, { + name: 'testuser', + }); + + Api.user(userId) + .then(({ data }) => { + expect(data.name).toBe('testuser'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('user status', () => { + it('fetches single user status', done => { + const userId = '123456'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}/status`; + mock.onGet(expectedUrl).reply(200, { + message: 'testmessage', + }); + + Api.userStatus(userId) + .then(({ data }) => { + expect(data.message).toBe('testmessage'); + }) + .then(done) + .catch(done.fail); + }); + }); + describe('commitPipelines', () => { it('fetches pipelines for a given commit', done => { const projectId = 'example/foobar'; diff --git a/spec/javascripts/boards/components/issue_due_date_spec.js b/spec/javascripts/boards/components/issue_due_date_spec.js index 9e49330c052..054cf8c5b7d 100644 --- a/spec/javascripts/boards/components/issue_due_date_spec.js +++ b/spec/javascripts/boards/components/issue_due_date_spec.js @@ -49,10 +49,11 @@ describe('Issue Due Date component', () => { it('should render month and day for other dates', () => { date.setDate(date.getDate() + 17); vm = createComponent(date); + const today = new Date(); + const isDueInCurrentYear = today.getFullYear() === date.getFullYear(); + const format = isDueInCurrentYear ? 'mmm d' : 'mmm d, yyyy'; - expect(vm.$el.querySelector('time').textContent.trim()).toEqual( - dateFormat(date, 'mmm d', true), - ); + expect(vm.$el.querySelector('time').textContent.trim()).toEqual(dateFormat(date, format, true)); }); it('should contain the correct `.text-danger` css class for overdue issue', () => { diff --git a/spec/javascripts/boards/mock_data.js b/spec/javascripts/boards/mock_data.js index c28e41ec175..14fff9223f4 100644 --- a/spec/javascripts/boards/mock_data.js +++ b/spec/javascripts/boards/mock_data.js @@ -1,5 +1,11 @@ import BoardService from '~/boards/services/board_service'; +export const boardObj = { + id: 1, + name: 'test', + milestone_id: null, +}; + export const listObj = { id: 300, position: 0, @@ -40,6 +46,12 @@ export const BoardsMockData = { }, ], }, + '/test/issue-boards/milestones.json': [ + { + id: 1, + title: 'test', + }, + ], }, POST: { '/test/-/boards/1/lists': listObj, @@ -70,3 +82,60 @@ export const mockBoardService = (opts = {}) => { boardId, }); }; + +export const mockAssigneesList = [ + { + id: 2, + name: 'Terrell Graham', + username: 'monserrate.gleichner', + state: 'active', + avatar_url: 'https://www.gravatar.com/avatar/598fd02741ac58b88854a99d16704309?s=80&d=identicon', + web_url: 'http://127.0.0.1:3001/monserrate.gleichner', + path: '/monserrate.gleichner', + }, + { + id: 12, + name: 'Susy Johnson', + username: 'tana_harvey', + state: 'active', + avatar_url: 'https://www.gravatar.com/avatar/e021a7b0f3e4ae53b5068d487e68c031?s=80&d=identicon', + web_url: 'http://127.0.0.1:3001/tana_harvey', + path: '/tana_harvey', + }, + { + id: 20, + name: 'Conchita Eichmann', + username: 'juliana_gulgowski', + state: 'active', + avatar_url: 'https://www.gravatar.com/avatar/c43c506cb6fd7b37017d3b54b94aa937?s=80&d=identicon', + web_url: 'http://127.0.0.1:3001/juliana_gulgowski', + path: '/juliana_gulgowski', + }, + { + id: 6, + name: 'Bryce Turcotte', + username: 'melynda', + state: 'active', + avatar_url: 'https://www.gravatar.com/avatar/cc2518f2c6f19f8fac49e1a5ee092a9b?s=80&d=identicon', + web_url: 'http://127.0.0.1:3001/melynda', + path: '/melynda', + }, + { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://127.0.0.1:3001/root', + path: '/root', + }, +]; + +export const mockMilestone = { + id: 1, + state: 'active', + title: 'Milestone title', + description: 'Harum corporis aut consequatur quae dolorem error sequi quia.', + start_date: '2018-01-01', + due_date: '2019-12-31', +}; diff --git a/spec/javascripts/diffs/store/actions_spec.js b/spec/javascripts/diffs/store/actions_spec.js index 55ce19927e0..033b5e86dbe 100644 --- a/spec/javascripts/diffs/store/actions_spec.js +++ b/spec/javascripts/diffs/store/actions_spec.js @@ -26,7 +26,9 @@ import actions, { toggleTreeOpen, scrollToFile, toggleShowTreeList, + renderFileForDiscussionId, } from '~/diffs/store/actions'; +import eventHub from '~/notes/event_hub'; import * as types from '~/diffs/store/mutation_types'; import axios from '~/lib/utils/axios_utils'; import mockDiffFile from 'spec/diffs/mock_data/diff_file'; @@ -735,4 +737,63 @@ describe('DiffsStoreActions', () => { expect(localStorage.setItem).toHaveBeenCalledWith('mr_tree_show', true); }); }); + + describe('renderFileForDiscussionId', () => { + const rootState = { + notes: { + discussions: [ + { + id: '123', + diff_file: { + file_hash: 'HASH', + }, + }, + { + id: '456', + diff_file: { + file_hash: 'HASH', + }, + }, + ], + }, + }; + let commit; + let $emit; + let scrollToElement; + const state = ({ collapsed, renderIt }) => ({ + diffFiles: [ + { + file_hash: 'HASH', + collapsed, + renderIt, + }, + ], + }); + + beforeEach(() => { + commit = jasmine.createSpy('commit'); + scrollToElement = spyOnDependency(actions, 'scrollToElement').and.stub(); + $emit = spyOn(eventHub, '$emit'); + }); + + it('renders and expands file for the given discussion id', () => { + const localState = state({ collapsed: true, renderIt: false }); + + renderFileForDiscussionId({ rootState, state: localState, commit }, '123'); + + expect(commit).toHaveBeenCalledWith('RENDER_FILE', localState.diffFiles[0]); + expect($emit).toHaveBeenCalledTimes(1); + expect(scrollToElement).toHaveBeenCalledTimes(1); + }); + + it('jumps to discussion on already rendered and expanded file', () => { + const localState = state({ collapsed: false, renderIt: true }); + + renderFileForDiscussionId({ rootState, state: localState, commit }, '123'); + + expect(commit).not.toHaveBeenCalled(); + expect($emit).toHaveBeenCalledTimes(1); + expect(scrollToElement).not.toHaveBeenCalled(); + }); + }); }); diff --git a/spec/javascripts/image_diff/helpers/badge_helper_spec.js b/spec/javascripts/image_diff/helpers/badge_helper_spec.js index 8ea05203d00..b3001d45e3c 100644 --- a/spec/javascripts/image_diff/helpers/badge_helper_spec.js +++ b/spec/javascripts/image_diff/helpers/badge_helper_spec.js @@ -61,6 +61,10 @@ describe('badge helper', () => { expect(buttonEl).toBeDefined(); }); + it('should add badge classes', () => { + expect(buttonEl.className).toContain('badge badge-pill'); + }); + it('should set the badge text', () => { expect(buttonEl.innerText).toEqual(badgeText); }); diff --git a/spec/javascripts/lib/utils/users_cache_spec.js b/spec/javascripts/lib/utils/users_cache_spec.js index 6adc19bdd51..acb5e024acd 100644 --- a/spec/javascripts/lib/utils/users_cache_spec.js +++ b/spec/javascripts/lib/utils/users_cache_spec.js @@ -3,7 +3,9 @@ import UsersCache from '~/lib/utils/users_cache'; describe('UsersCache', () => { const dummyUsername = 'win'; - const dummyUser = 'has a farm'; + const dummyUserId = 123; + const dummyUser = { name: 'has a farm', username: 'farmer' }; + const dummyUserStatus = 'my status'; beforeEach(() => { UsersCache.internalStorage = {}; @@ -135,4 +137,110 @@ describe('UsersCache', () => { .catch(done.fail); }); }); + + describe('retrieveById', () => { + let apiSpy; + + beforeEach(() => { + spyOn(Api, 'user').and.callFake(id => apiSpy(id)); + }); + + it('stores and returns data from API call if cache is empty', done => { + apiSpy = id => { + expect(id).toBe(dummyUserId); + return Promise.resolve({ + data: dummyUser, + }); + }; + + UsersCache.retrieveById(dummyUserId) + .then(user => { + expect(user).toBe(dummyUser); + expect(UsersCache.internalStorage[dummyUserId]).toBe(dummyUser); + }) + .then(done) + .catch(done.fail); + }); + + it('returns undefined if Ajax call fails and cache is empty', done => { + const dummyError = new Error('server exploded'); + apiSpy = id => { + expect(id).toBe(dummyUserId); + return Promise.reject(dummyError); + }; + + UsersCache.retrieveById(dummyUserId) + .then(user => fail(`Received unexpected user: ${JSON.stringify(user)}`)) + .catch(error => { + expect(error).toBe(dummyError); + }) + .then(done) + .catch(done.fail); + }); + + it('makes no Ajax call if matching data exists', done => { + UsersCache.internalStorage[dummyUserId] = dummyUser; + apiSpy = () => fail(new Error('expected no Ajax call!')); + + UsersCache.retrieveById(dummyUserId) + .then(user => { + expect(user).toBe(dummyUser); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('retrieveStatusById', () => { + let apiSpy; + + beforeEach(() => { + spyOn(Api, 'userStatus').and.callFake(id => apiSpy(id)); + }); + + it('stores and returns data from API call if cache is empty', done => { + apiSpy = id => { + expect(id).toBe(dummyUserId); + return Promise.resolve({ + data: dummyUserStatus, + }); + }; + + UsersCache.retrieveStatusById(dummyUserId) + .then(userStatus => { + expect(userStatus).toBe(dummyUserStatus); + expect(UsersCache.internalStorage[dummyUserId].status).toBe(dummyUserStatus); + }) + .then(done) + .catch(done.fail); + }); + + it('returns undefined if Ajax call fails and cache is empty', done => { + const dummyError = new Error('server exploded'); + apiSpy = id => { + expect(id).toBe(dummyUserId); + return Promise.reject(dummyError); + }; + + UsersCache.retrieveStatusById(dummyUserId) + .then(userStatus => fail(`Received unexpected user: ${JSON.stringify(userStatus)}`)) + .catch(error => { + expect(error).toBe(dummyError); + }) + .then(done) + .catch(done.fail); + }); + + it('makes no Ajax call if matching data exists', done => { + UsersCache.internalStorage[dummyUserId] = { status: dummyUserStatus }; + apiSpy = () => fail(new Error('expected no Ajax call!')); + + UsersCache.retrieveStatusById(dummyUserId) + .then(userStatus => { + expect(userStatus).toBe(dummyUserStatus); + }) + .then(done) + .catch(done.fail); + }); + }); }); diff --git a/spec/javascripts/notes/components/note_edited_text_spec.js b/spec/javascripts/notes/components/note_edited_text_spec.js index e0b991c32ec..e4c8d954d50 100644 --- a/spec/javascripts/notes/components/note_edited_text_spec.js +++ b/spec/javascripts/notes/components/note_edited_text_spec.js @@ -39,7 +39,7 @@ describe('note_edited_text', () => { }); it('should render provided user information', () => { - const authorLink = vm.$el.querySelector('.js-vue-author'); + const authorLink = vm.$el.querySelector('.js-user-link'); expect(authorLink.getAttribute('href')).toEqual(props.editedBy.path); expect(authorLink.textContent.trim()).toEqual(props.editedBy.name); diff --git a/spec/javascripts/notes/components/note_header_spec.js b/spec/javascripts/notes/components/note_header_spec.js index 379780f43a0..6d1a7ef370f 100644 --- a/spec/javascripts/notes/components/note_header_spec.js +++ b/spec/javascripts/notes/components/note_header_spec.js @@ -42,6 +42,9 @@ describe('note_header component', () => { it('should render user information', () => { expect(vm.$el.querySelector('.note-header-author-name').textContent.trim()).toEqual('Root'); expect(vm.$el.querySelector('.note-header-info a').getAttribute('href')).toEqual('/root'); + expect(vm.$el.querySelector('.note-header-info a').dataset.userId).toEqual('1'); + expect(vm.$el.querySelector('.note-header-info a').dataset.username).toEqual('root'); + expect(vm.$el.querySelector('.note-header-info a').classList).toContain('js-user-link'); }); it('should render timestamp link', () => { diff --git a/spec/javascripts/notes/components/noteable_discussion_spec.js b/spec/javascripts/notes/components/noteable_discussion_spec.js index ab9c52346d6..e4d29a3860c 100644 --- a/spec/javascripts/notes/components/noteable_discussion_spec.js +++ b/spec/javascripts/notes/components/noteable_discussion_spec.js @@ -83,6 +83,7 @@ describe('noteable_discussion component', () => { it('expands next unresolved discussion', done => { const discussion2 = getJSONFixture(discussionWithTwoUnresolvedNotes)[0]; discussion2.resolved = false; + discussion2.active = true; discussion2.id = 'next'; // prepare this for being identified as next one (to be jumped to) vm.$store.dispatch('setInitialNotes', [discussionMock, discussion2]); window.mrTabs.currentAction = 'show'; diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js index ad0e793b915..7ae45c40c28 100644 --- a/spec/javascripts/notes/mock_data.js +++ b/spec/javascripts/notes/mock_data.js @@ -305,6 +305,7 @@ export const discussionMock = { ], individual_note: false, resolvable: true, + active: true, }; export const loggedOutnoteableData = { @@ -1173,6 +1174,7 @@ export const discussion1 = { id: 'abc1', resolvable: true, resolved: false, + active: true, diff_file: { file_path: 'about.md', }, @@ -1209,6 +1211,7 @@ export const discussion2 = { id: 'abc2', resolvable: true, resolved: false, + active: true, diff_file: { file_path: 'README.md', }, @@ -1226,6 +1229,7 @@ export const discussion2 = { export const discussion3 = { id: 'abc3', resolvable: true, + active: true, resolved: false, diff_file: { file_path: 'README.md', diff --git a/spec/javascripts/notes/stores/actions_spec.js b/spec/javascripts/notes/stores/actions_spec.js index 24c2b3e6570..2e3cd5e8f36 100644 --- a/spec/javascripts/notes/stores/actions_spec.js +++ b/spec/javascripts/notes/stores/actions_spec.js @@ -124,7 +124,7 @@ describe('Actions Notes Store', () => { { discussionId: discussionMock.id }, { notes: [discussionMock] }, [{ type: 'EXPAND_DISCUSSION', payload: { discussionId: discussionMock.id } }], - [], + [{ type: 'diffs/renderFileForDiscussionId', payload: discussionMock.id }], done, ); }); diff --git a/spec/javascripts/registry/components/app_spec.js b/spec/javascripts/registry/components/app_spec.js index 92ff960277a..67118ac03a5 100644 --- a/spec/javascripts/registry/components/app_spec.js +++ b/spec/javascripts/registry/components/app_spec.js @@ -1,37 +1,30 @@ -import _ from 'underscore'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; import Vue from 'vue'; import registry from '~/registry/components/app.vue'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { TEST_HOST } from 'spec/test_constants'; import { reposServerResponse } from '../mock_data'; describe('Registry List', () => { + const Component = Vue.extend(registry); let vm; - let Component; + let mock; beforeEach(() => { - Component = Vue.extend(registry); + mock = new MockAdapter(axios); }); afterEach(() => { + mock.restore(); vm.$destroy(); }); describe('with data', () => { - const interceptor = (request, next) => { - next( - request.respondWith(JSON.stringify(reposServerResponse), { - status: 200, - }), - ); - }; - beforeEach(() => { - Vue.http.interceptors.push(interceptor); - vm = mountComponent(Component, { endpoint: 'foo' }); - }); + mock.onGet(`${TEST_HOST}/foo`).replyOnce(200, reposServerResponse); - afterEach(() => { - Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor); + vm = mountComponent(Component, { endpoint: `${TEST_HOST}/foo` }); }); it('should render a list of repos', done => { @@ -64,9 +57,9 @@ describe('Registry List', () => { Vue.nextTick(() => { vm.$el.querySelector('.js-toggle-repo').click(); Vue.nextTick(() => { - expect(vm.$el.querySelector('.js-toggle-repo i').className).toEqual( - 'fa fa-chevron-up', - ); + expect( + vm.$el.querySelector('.js-toggle-repo use').getAttribute('xlink:href'), + ).toContain('angle-up'); done(); }); }); @@ -76,21 +69,10 @@ describe('Registry List', () => { }); describe('without data', () => { - const interceptor = (request, next) => { - next( - request.respondWith(JSON.stringify([]), { - status: 200, - }), - ); - }; - beforeEach(() => { - Vue.http.interceptors.push(interceptor); - vm = mountComponent(Component, { endpoint: 'foo' }); - }); + mock.onGet(`${TEST_HOST}/foo`).replyOnce(200, []); - afterEach(() => { - Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor); + vm = mountComponent(Component, { endpoint: `${TEST_HOST}/foo` }); }); it('should render empty message', done => { @@ -109,21 +91,10 @@ describe('Registry List', () => { }); describe('while loading data', () => { - const interceptor = (request, next) => { - next( - request.respondWith(JSON.stringify(reposServerResponse), { - status: 200, - }), - ); - }; - beforeEach(() => { - Vue.http.interceptors.push(interceptor); - vm = mountComponent(Component, { endpoint: 'foo' }); - }); + mock.onGet(`${TEST_HOST}/foo`).replyOnce(200, []); - afterEach(() => { - Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor); + vm = mountComponent(Component, { endpoint: `${TEST_HOST}/foo` }); }); it('should render a loading spinner', done => { diff --git a/spec/javascripts/registry/components/collapsible_container_spec.js b/spec/javascripts/registry/components/collapsible_container_spec.js index 256a242f784..a3f7ff76dc7 100644 --- a/spec/javascripts/registry/components/collapsible_container_spec.js +++ b/spec/javascripts/registry/components/collapsible_container_spec.js @@ -1,14 +1,24 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; import Vue from 'vue'; import collapsibleComponent from '~/registry/components/collapsible_container.vue'; import store from '~/registry/stores'; -import { repoPropsData } from '../mock_data'; +import * as types from '~/registry/stores/mutation_types'; + +import { repoPropsData, registryServerResponse, reposServerResponse } from '../mock_data'; describe('collapsible registry container', () => { let vm; - let Component; + let mock; + const Component = Vue.extend(collapsibleComponent); beforeEach(() => { - Component = Vue.extend(collapsibleComponent); + mock = new MockAdapter(axios); + + mock.onGet(repoPropsData.tagsPath).replyOnce(200, registryServerResponse, {}); + + store.commit(types.SET_REPOS_LIST, reposServerResponse); + vm = new Component({ store, propsData: { @@ -18,24 +28,23 @@ describe('collapsible registry container', () => { }); afterEach(() => { + mock.restore(); vm.$destroy(); }); describe('toggle', () => { it('should be closed by default', () => { expect(vm.$el.querySelector('.container-image-tags')).toBe(null); - expect(vm.$el.querySelector('.container-image-head i').className).toEqual( - 'fa fa-chevron-right', - ); + expect(vm.iconName).toEqual('angle-right'); }); it('should be open when user clicks on closed repo', done => { vm.$el.querySelector('.js-toggle-repo').click(); + Vue.nextTick(() => { - expect(vm.$el.querySelector('.container-image-tags')).toBeDefined(); - expect(vm.$el.querySelector('.container-image-head i').className).toEqual( - 'fa fa-chevron-up', - ); + expect(vm.$el.querySelector('.container-image-tags')).not.toBeNull(); + expect(vm.iconName).toEqual('angle-up'); + done(); }); }); @@ -45,12 +54,12 @@ describe('collapsible registry container', () => { Vue.nextTick(() => { vm.$el.querySelector('.js-toggle-repo').click(); - Vue.nextTick(() => { - expect(vm.$el.querySelector('.container-image-tags')).toBe(null); - expect(vm.$el.querySelector('.container-image-head i').className).toEqual( - 'fa fa-chevron-right', - ); - done(); + setTimeout(() => { + Vue.nextTick(() => { + expect(vm.$el.querySelector('.container-image-tags')).toBe(null); + expect(vm.iconName).toEqual('angle-right'); + done(); + }); }); }); }); @@ -58,7 +67,7 @@ describe('collapsible registry container', () => { describe('delete repo', () => { it('should be possible to delete a repo', () => { - expect(vm.$el.querySelector('.js-remove-repo')).toBeDefined(); + expect(vm.$el.querySelector('.js-remove-repo')).not.toBeNull(); }); }); }); diff --git a/spec/javascripts/registry/stores/actions_spec.js b/spec/javascripts/registry/stores/actions_spec.js index bc4c444655a..c9aa82dba90 100644 --- a/spec/javascripts/registry/stores/actions_spec.js +++ b/spec/javascripts/registry/stores/actions_spec.js @@ -1,42 +1,34 @@ -import Vue from 'vue'; -import VueResource from 'vue-resource'; -import _ from 'underscore'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; import * as actions from '~/registry/stores/actions'; import * as types from '~/registry/stores/mutation_types'; +import state from '~/registry/stores/state'; +import { TEST_HOST } from 'spec/test_constants'; import testAction from '../../helpers/vuex_action_helper'; import { - defaultState, reposServerResponse, registryServerResponse, parsedReposServerResponse, } from '../mock_data'; -Vue.use(VueResource); - describe('Actions Registry Store', () => { - let interceptor; let mockedState; + let mock; beforeEach(() => { - mockedState = defaultState; + mockedState = state(); + mockedState.endpoint = `${TEST_HOST}/endpoint.json`; + mock = new MockAdapter(axios); }); - describe('server requests', () => { - afterEach(() => { - Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor); - }); + afterEach(() => { + mock.restore(); + }); + describe('server requests', () => { describe('fetchRepos', () => { beforeEach(() => { - interceptor = (request, next) => { - next( - request.respondWith(JSON.stringify(reposServerResponse), { - status: 200, - }), - ); - }; - - Vue.http.interceptors.push(interceptor); + mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, reposServerResponse, {}); }); it('should set receveived repos', done => { @@ -56,23 +48,15 @@ describe('Actions Registry Store', () => { }); describe('fetchList', () => { + let repo; beforeEach(() => { - interceptor = (request, next) => { - next( - request.respondWith(JSON.stringify(registryServerResponse), { - status: 200, - }), - ); - }; + mockedState.repos = parsedReposServerResponse; + [, repo] = mockedState.repos; - Vue.http.interceptors.push(interceptor); + mock.onGet(repo.tagsPath).replyOnce(200, registryServerResponse, {}); }); it('should set received list', done => { - mockedState.repos = parsedReposServerResponse; - - const repo = mockedState.repos[1]; - testAction( actions.fetchList, { repo }, diff --git a/spec/javascripts/user_popovers_spec.js b/spec/javascripts/user_popovers_spec.js new file mode 100644 index 00000000000..6cf8dd81b36 --- /dev/null +++ b/spec/javascripts/user_popovers_spec.js @@ -0,0 +1,66 @@ +import initUserPopovers from '~/user_popovers'; +import UsersCache from '~/lib/utils/users_cache'; + +describe('User Popovers', () => { + const selector = '.js-user-link'; + + const dummyUser = { name: 'root' }; + const dummyUserStatus = { message: 'active' }; + + const triggerEvent = (eventName, el) => { + const event = document.createEvent('MouseEvents'); + event.initMouseEvent(eventName, true, true, window); + + el.dispatchEvent(event); + }; + + beforeEach(() => { + setFixtures(` + <a href="/root" data-user-id="1" class="js-user-link" data-username="root" data-original-title="" title=""> + Root + </a> + `); + + const usersCacheSpy = () => Promise.resolve(dummyUser); + spyOn(UsersCache, 'retrieveById').and.callFake(userId => usersCacheSpy(userId)); + + const userStatusCacheSpy = () => Promise.resolve(dummyUserStatus); + spyOn(UsersCache, 'retrieveStatusById').and.callFake(userId => userStatusCacheSpy(userId)); + + initUserPopovers(document.querySelectorAll('.js-user-link')); + }); + + it('Should Show+Hide Popover on mouseenter and mouseleave', done => { + triggerEvent('mouseenter', document.querySelector(selector)); + + setTimeout(() => { + const shownPopover = document.querySelector('.popover'); + + expect(shownPopover).not.toBeNull(); + + expect(shownPopover.innerHTML).toContain(dummyUser.name); + expect(UsersCache.retrieveById).toHaveBeenCalledWith('1'); + + triggerEvent('mouseleave', document.querySelector(selector)); + + setTimeout(() => { + // After Mouse leave it should be hidden now + expect(document.querySelector('.popover')).toBeNull(); + done(); + }); + }, 210); // We need to wait until the 200ms mouseover delay is over, only then the popover will be visible + }); + + it('Should Not show a popover on short mouse over', done => { + triggerEvent('mouseenter', document.querySelector(selector)); + + setTimeout(() => { + expect(document.querySelector('.popover')).toBeNull(); + expect(UsersCache.retrieveById).not.toHaveBeenCalledWith('1'); + + triggerEvent('mouseleave', document.querySelector(selector)); + + done(); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/issue/issue_assignees_spec.js b/spec/javascripts/vue_shared/components/issue/issue_assignees_spec.js new file mode 100644 index 00000000000..9eac75fac96 --- /dev/null +++ b/spec/javascripts/vue_shared/components/issue/issue_assignees_spec.js @@ -0,0 +1,114 @@ +import Vue from 'vue'; + +import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; + +import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { mockAssigneesList } from 'spec/boards/mock_data'; + +const createComponent = (assignees = mockAssigneesList, cssClass = '') => { + const Component = Vue.extend(IssueAssignees); + + return mountComponent(Component, { + assignees, + cssClass, + }); +}; + +describe('IssueAssigneesComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('data', () => { + it('returns default data props', () => { + expect(vm.maxVisibleAssignees).toBe(2); + expect(vm.maxAssigneeAvatars).toBe(3); + expect(vm.maxAssignees).toBe(99); + }); + }); + + describe('computed', () => { + describe('countOverLimit', () => { + it('should return difference between assignees count and maxVisibleAssignees', () => { + expect(vm.countOverLimit).toBe(mockAssigneesList.length - vm.maxVisibleAssignees); + }); + }); + + describe('assigneesToShow', () => { + it('should return assignees containing only 2 items when count more than maxAssigneeAvatars', () => { + expect(vm.assigneesToShow.length).toBe(2); + }); + + it('should return all assignees as it is when count less than maxAssigneeAvatars', () => { + vm.assignees = mockAssigneesList.slice(0, 3); // Set 3 Assignees + + expect(vm.assigneesToShow.length).toBe(3); + }); + }); + + describe('assigneesCounterTooltip', () => { + it('should return string containing count of remaining assignees when count more than maxAssigneeAvatars', () => { + expect(vm.assigneesCounterTooltip).toBe('3 more assignees'); + }); + }); + + describe('shouldRenderAssigneesCounter', () => { + it('should return `false` when assignees count less than maxAssigneeAvatars', () => { + vm.assignees = mockAssigneesList.slice(0, 3); // Set 3 Assignees + + expect(vm.shouldRenderAssigneesCounter).toBe(false); + }); + + it('should return `true` when assignees count more than maxAssigneeAvatars', () => { + expect(vm.shouldRenderAssigneesCounter).toBe(true); + }); + }); + + describe('assigneeCounterLabel', () => { + it('should return count of additional assignees total assignees count more than maxAssigneeAvatars', () => { + expect(vm.assigneeCounterLabel).toBe('+3'); + }); + }); + }); + + describe('methods', () => { + describe('avatarUrlTitle', () => { + it('returns string containing alt text for assignee avatar', () => { + expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Avatar for Terrell Graham'); + }); + }); + }); + + describe('template', () => { + it('renders component root element with class `issue-assignees`', () => { + expect(vm.$el.classList.contains('issue-assignees')).toBe(true); + }); + + it('renders assignee avatars', () => { + expect(vm.$el.querySelectorAll('.user-avatar-link').length).toBe(2); + }); + + it('renders assignee tooltips', () => { + const tooltipText = vm.$el + .querySelectorAll('.user-avatar-link')[0] + .querySelector('.js-assignee-tooltip').innerText; + + expect(tooltipText).toContain('Assignee'); + expect(tooltipText).toContain('Terrell Graham'); + expect(tooltipText).toContain('@monserrate.gleichner'); + }); + + it('renders additional assignees count', () => { + const avatarCounterEl = vm.$el.querySelector('.avatar-counter'); + + expect(avatarCounterEl.innerText.trim()).toBe('+3'); + expect(avatarCounterEl.getAttribute('data-original-title')).toBe('3 more assignees'); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/issue/issue_milestone_spec.js b/spec/javascripts/vue_shared/components/issue/issue_milestone_spec.js new file mode 100644 index 00000000000..8fca2637326 --- /dev/null +++ b/spec/javascripts/vue_shared/components/issue/issue_milestone_spec.js @@ -0,0 +1,234 @@ +import Vue from 'vue'; + +import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue'; + +import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { mockMilestone } from 'spec/boards/mock_data'; + +const createComponent = (milestone = mockMilestone) => { + const Component = Vue.extend(IssueMilestone); + + return mountComponent(Component, { + milestone, + }); +}; + +describe('IssueMilestoneComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('isMilestoneStarted', () => { + it('should return `false` when milestoneStart prop is not defined', done => { + const vmStartUndefined = createComponent( + Object.assign({}, mockMilestone, { + start_date: '', + }), + ); + + Vue.nextTick() + .then(() => { + expect(vmStartUndefined.isMilestoneStarted).toBe(false); + }) + .then(done) + .catch(done.fail); + + vmStartUndefined.$destroy(); + }); + + it('should return `true` when milestone start date is past current date', done => { + const vmStarted = createComponent( + Object.assign({}, mockMilestone, { + start_date: '1990-07-22', + }), + ); + + Vue.nextTick() + .then(() => { + expect(vmStarted.isMilestoneStarted).toBe(true); + }) + .then(done) + .catch(done.fail); + + vmStarted.$destroy(); + }); + }); + + describe('isMilestonePastDue', () => { + it('should return `false` when milestoneDue prop is not defined', done => { + const vmDueUndefined = createComponent( + Object.assign({}, mockMilestone, { + due_date: '', + }), + ); + + Vue.nextTick() + .then(() => { + expect(vmDueUndefined.isMilestonePastDue).toBe(false); + }) + .then(done) + .catch(done.fail); + + vmDueUndefined.$destroy(); + }); + + it('should return `true` when milestone due is past current date', done => { + const vmPastDue = createComponent( + Object.assign({}, mockMilestone, { + due_date: '1990-07-22', + }), + ); + + Vue.nextTick() + .then(() => { + expect(vmPastDue.isMilestonePastDue).toBe(true); + }) + .then(done) + .catch(done.fail); + + vmPastDue.$destroy(); + }); + }); + + describe('milestoneDatesAbsolute', () => { + it('returns string containing absolute milestone due date', () => { + expect(vm.milestoneDatesAbsolute).toBe('(December 31, 2019)'); + }); + + it('returns string containing absolute milestone start date when due date is not present', done => { + const vmDueUndefined = createComponent( + Object.assign({}, mockMilestone, { + due_date: '', + }), + ); + + Vue.nextTick() + .then(() => { + expect(vmDueUndefined.milestoneDatesAbsolute).toBe('(January 1, 2018)'); + }) + .then(done) + .catch(done.fail); + + vmDueUndefined.$destroy(); + }); + + it('returns empty string when both milestone start and due dates are not present', done => { + const vmDatesUndefined = createComponent( + Object.assign({}, mockMilestone, { + start_date: '', + due_date: '', + }), + ); + + Vue.nextTick() + .then(() => { + expect(vmDatesUndefined.milestoneDatesAbsolute).toBe(''); + }) + .then(done) + .catch(done.fail); + + vmDatesUndefined.$destroy(); + }); + }); + + describe('milestoneDatesHuman', () => { + it('returns string containing milestone due date when date is yet to be due', done => { + const vmFuture = createComponent( + Object.assign({}, mockMilestone, { + due_date: `${new Date().getFullYear() + 10}-01-01`, + }), + ); + + Vue.nextTick() + .then(() => { + expect(vmFuture.milestoneDatesHuman).toContain('years remaining'); + }) + .then(done) + .catch(done.fail); + + vmFuture.$destroy(); + }); + + it('returns string containing milestone start date when date has already started and due date is not present', done => { + const vmStarted = createComponent( + Object.assign({}, mockMilestone, { + start_date: '1990-07-22', + due_date: '', + }), + ); + + Vue.nextTick() + .then(() => { + expect(vmStarted.milestoneDatesHuman).toContain('Started'); + }) + .then(done) + .catch(done.fail); + + vmStarted.$destroy(); + }); + + it('returns string containing milestone start date when date is yet to start and due date is not present', done => { + const vmStarts = createComponent( + Object.assign({}, mockMilestone, { + start_date: `${new Date().getFullYear() + 10}-01-01`, + due_date: '', + }), + ); + + Vue.nextTick() + .then(() => { + expect(vmStarts.milestoneDatesHuman).toContain('Starts'); + }) + .then(done) + .catch(done.fail); + + vmStarts.$destroy(); + }); + + it('returns empty string when milestone start and due dates are not present', done => { + const vmDatesUndefined = createComponent( + Object.assign({}, mockMilestone, { + start_date: '', + due_date: '', + }), + ); + + Vue.nextTick() + .then(() => { + expect(vmDatesUndefined.milestoneDatesHuman).toBe(''); + }) + .then(done) + .catch(done.fail); + + vmDatesUndefined.$destroy(); + }); + }); + }); + + describe('template', () => { + it('renders component root element with class `issue-milestone-details`', () => { + expect(vm.$el.classList.contains('issue-milestone-details')).toBe(true); + }); + + it('renders milestone icon', () => { + expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('clock'); + }); + + it('renders milestone title', () => { + expect(vm.$el.querySelector('.milestone-title').innerText.trim()).toBe(mockMilestone.title); + }); + + it('renders milestone tooltip', () => { + expect(vm.$el.querySelector('.js-item-milestone').innerText.trim()).toContain( + mockMilestone.title, + ); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js index 5c4aa7cf844..c5045afc5b0 100644 --- a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js +++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import { placeholderImage } from '~/lazy_loader'; import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; import mountComponent, { mountComponentWithSlots } from 'spec/helpers/vue_mount_component_helper'; +import defaultAvatarUrl from '~/../images/no_avatar.png'; const DEFAULT_PROPS = { size: 99, @@ -76,6 +77,18 @@ describe('User Avatar Image Component', function() { }); }); + describe('Initialization without src', function() { + beforeEach(function() { + vm = mountComponent(UserAvatarImage); + }); + + it('should have default avatar image', function() { + const imageElement = vm.$el.querySelector('img'); + + expect(imageElement.getAttribute('src')).toBe(defaultAvatarUrl); + }); + }); + describe('dynamic tooltip content', () => { const props = DEFAULT_PROPS; const slots = { diff --git a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js index 0151ad23ba2..f2472fd377c 100644 --- a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js +++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js @@ -74,9 +74,7 @@ describe('User Avatar Link Component', function() { describe('username', function() { it('should not render avatar image tooltip', function() { - expect( - this.userAvatarLink.$el.querySelector('.js-user-avatar-image-toolip').innerText.trim(), - ).toEqual(''); + expect(this.userAvatarLink.$el.querySelector('.js-user-avatar-image-toolip')).toBeNull(); }); it('should render username prop in <span>', function() { diff --git a/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js b/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js new file mode 100644 index 00000000000..1578b0f81f9 --- /dev/null +++ b/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js @@ -0,0 +1,133 @@ +import Vue from 'vue'; +import userPopover from '~/vue_shared/components/user_popover/user_popover.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +const DEFAULT_PROPS = { + loaded: true, + user: { + username: 'root', + name: 'Administrator', + location: 'Vienna', + bio: null, + organization: null, + status: null, + }, +}; + +const UserPopover = Vue.extend(userPopover); + +describe('User Popover Component', () => { + let vm; + + beforeEach(() => { + setFixtures(` + <a href="/root" data-user-id="1" class="js-user-link" title="testuser"> + Root + </a> + `); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('Empty', () => { + beforeEach(() => { + vm = mountComponent(UserPopover, { + target: document.querySelector('.js-user-link'), + user: { + name: null, + username: null, + location: null, + bio: null, + organization: null, + status: null, + }, + }); + }); + + it('should return skeleton loaders', () => { + expect(vm.$el.querySelectorAll('.animation-container').length).toBe(4); + }); + }); + + describe('basic data', () => { + it('should show basic fields', () => { + vm = mountComponent(UserPopover, { + ...DEFAULT_PROPS, + target: document.querySelector('.js-user-link'), + }); + + expect(vm.$el.textContent).toContain(DEFAULT_PROPS.user.name); + expect(vm.$el.textContent).toContain(DEFAULT_PROPS.user.username); + expect(vm.$el.textContent).toContain(DEFAULT_PROPS.user.location); + }); + }); + + describe('job data', () => { + it('should show only bio if no organization is available', () => { + const testProps = Object.assign({}, DEFAULT_PROPS); + testProps.user.bio = 'Engineer'; + + vm = mountComponent(UserPopover, { + ...testProps, + target: document.querySelector('.js-user-link'), + }); + + expect(vm.$el.textContent).toContain('Engineer'); + }); + + it('should show only organization if no bio is available', () => { + const testProps = Object.assign({}, DEFAULT_PROPS); + testProps.user.organization = 'GitLab'; + + vm = mountComponent(UserPopover, { + ...testProps, + target: document.querySelector('.js-user-link'), + }); + + expect(vm.$el.textContent).toContain('GitLab'); + }); + + it('should have full job line when we have bio and organization', () => { + const testProps = Object.assign({}, DEFAULT_PROPS); + testProps.user.bio = 'Engineer'; + testProps.user.organization = 'GitLab'; + + vm = mountComponent(UserPopover, { + ...DEFAULT_PROPS, + target: document.querySelector('.js-user-link'), + }); + + expect(vm.$el.textContent).toContain('Engineer at GitLab'); + }); + }); + + describe('status data', () => { + it('should show only message', () => { + const testProps = Object.assign({}, DEFAULT_PROPS); + testProps.user.status = { message: 'Hello World' }; + + vm = mountComponent(UserPopover, { + ...DEFAULT_PROPS, + target: document.querySelector('.js-user-link'), + }); + + expect(vm.$el.textContent).toContain('Hello World'); + }); + + it('should show message and emoji', () => { + const testProps = Object.assign({}, DEFAULT_PROPS); + testProps.user.status = { emoji: 'basketball_player', message: 'Hello World' }; + + vm = mountComponent(UserPopover, { + ...DEFAULT_PROPS, + target: document.querySelector('.js-user-link'), + status: { emoji: 'basketball_player', message: 'Hello World' }, + }); + + expect(vm.$el.textContent).toContain('Hello World'); + expect(vm.$el.innerHTML).toContain('<gl-emoji data-name="basketball_player"'); + }); + }); +}); diff --git a/spec/lib/banzai/filter/user_reference_filter_spec.rb b/spec/lib/banzai/filter/user_reference_filter_spec.rb index 334d29a5368..1e8a44b4549 100644 --- a/spec/lib/banzai/filter/user_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/user_reference_filter_spec.rb @@ -120,7 +120,7 @@ describe Banzai::Filter::UserReferenceFilter do it 'includes default classes' do doc = reference_filter("Hey #{reference}") - expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-project_member has-tooltip' + expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-project_member' end context 'when a project is not specified' do diff --git a/spec/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_improved_spec.rb b/spec/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_improved_spec.rb new file mode 100644 index 00000000000..d1d64574627 --- /dev/null +++ b/spec/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_improved_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Gitlab::BackgroundMigration::PopulateMergeRequestMetricsWithEventsDataImproved, :migration, schema: 20181204154019 do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:users) { table(:users) } + let(:events) { table(:events) } + + let(:user) { users.create!(email: 'test@example.com', projects_limit: 100, username: 'test') } + + let(:namespace) { namespaces.create(name: 'gitlab', path: 'gitlab-org') } + let(:project) { projects.create(namespace_id: namespace.id, name: 'foo') } + let(:merge_requests) { table(:merge_requests) } + + def create_merge_request(id, params = {}) + params.merge!(id: id, + target_project_id: project.id, + target_branch: 'master', + source_project_id: project.id, + source_branch: 'mr name', + title: "mr name#{id}") + + merge_requests.create(params) + end + + def create_merge_request_event(id, params = {}) + params.merge!(id: id, + project_id: project.id, + author_id: user.id, + target_type: 'MergeRequest') + + events.create(params) + end + + describe '#perform' do + it 'creates and updates closed and merged events' do + timestamp = Time.new('2018-01-01 12:00:00').utc + + create_merge_request(1) + create_merge_request_event(1, target_id: 1, action: 3, updated_at: timestamp) + create_merge_request_event(2, target_id: 1, action: 3, updated_at: timestamp + 10.seconds) + + create_merge_request_event(3, target_id: 1, action: 7, updated_at: timestamp) + create_merge_request_event(4, target_id: 1, action: 7, updated_at: timestamp + 10.seconds) + + subject.perform(1, 1) + + merge_request = MergeRequest.first + + expect(merge_request.metrics).to have_attributes(latest_closed_by_id: user.id, + latest_closed_at: timestamp + 10.seconds, + merged_by_id: user.id) + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/except_policy_spec.rb b/spec/lib/gitlab/ci/config/entry/except_policy_spec.rb new file mode 100644 index 00000000000..d036bf2f4d1 --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/except_policy_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::ExceptPolicy do + let(:entry) { described_class.new(config) } + + it_behaves_like 'correct only except policy' + + describe '.default' do + it 'does not have a default value' do + expect(described_class.default).to be_nil + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/only_policy_spec.rb b/spec/lib/gitlab/ci/config/entry/only_policy_spec.rb new file mode 100644 index 00000000000..5518b68e51a --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/only_policy_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::OnlyPolicy do + let(:entry) { described_class.new(config) } + + it_behaves_like 'correct only except policy' + + describe '.default' do + it 'haa a default value' do + expect(described_class.default).to eq( { refs: %w[branches tags] } ) + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/policy_spec.rb b/spec/lib/gitlab/ci/config/entry/policy_spec.rb index 83001b7fdd8..cf40a22af2e 100644 --- a/spec/lib/gitlab/ci/config/entry/policy_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/policy_spec.rb @@ -1,173 +1,8 @@ -require 'fast_spec_helper' -require_dependency 'active_model' +require 'spec_helper' describe Gitlab::Ci::Config::Entry::Policy do let(:entry) { described_class.new(config) } - context 'when using simplified policy' do - describe 'validations' do - context 'when entry config value is valid' do - context 'when config is a branch or tag name' do - let(:config) { %w[master feature/branch] } - - describe '#valid?' do - it 'is valid' do - expect(entry).to be_valid - end - end - - describe '#value' do - it 'returns refs hash' do - expect(entry.value).to eq(refs: config) - end - end - end - - context 'when config is a regexp' do - let(:config) { ['/^issue-.*$/'] } - - describe '#valid?' do - it 'is valid' do - expect(entry).to be_valid - end - end - end - - context 'when config is a special keyword' do - let(:config) { %w[tags triggers branches] } - - describe '#valid?' do - it 'is valid' do - expect(entry).to be_valid - end - end - end - end - - context 'when entry value is not valid' do - let(:config) { [1] } - - describe '#errors' do - it 'saves errors' do - expect(entry.errors) - .to include /policy config should be an array of strings or regexps/ - end - end - end - end - end - - context 'when using complex policy' do - context 'when specifying refs policy' do - let(:config) { { refs: ['master'] } } - - it 'is a correct configuraton' do - expect(entry).to be_valid - expect(entry.value).to eq(refs: %w[master]) - end - end - - context 'when specifying kubernetes policy' do - let(:config) { { kubernetes: 'active' } } - - it 'is a correct configuraton' do - expect(entry).to be_valid - expect(entry.value).to eq(kubernetes: 'active') - end - end - - context 'when specifying invalid kubernetes policy' do - let(:config) { { kubernetes: 'something' } } - - it 'reports an error about invalid policy' do - expect(entry.errors).to include /unknown value: something/ - end - end - - context 'when specifying valid variables expressions policy' do - let(:config) { { variables: ['$VAR == null'] } } - - it 'is a correct configuraton' do - expect(entry).to be_valid - expect(entry.value).to eq(config) - end - end - - context 'when specifying variables expressions in invalid format' do - let(:config) { { variables: '$MY_VAR' } } - - it 'reports an error about invalid format' do - expect(entry.errors).to include /should be an array of strings/ - end - end - - context 'when specifying invalid variables expressions statement' do - let(:config) { { variables: ['$MY_VAR =='] } } - - it 'reports an error about invalid statement' do - expect(entry.errors).to include /invalid expression syntax/ - end - end - - context 'when specifying invalid variables expressions token' do - let(:config) { { variables: ['$MY_VAR == 123'] } } - - it 'reports an error about invalid expression' do - expect(entry.errors).to include /invalid expression syntax/ - end - end - - context 'when using invalid variables expressions regexp' do - let(:config) { { variables: ['$MY_VAR =~ /some ( thing/'] } } - - it 'reports an error about invalid expression' do - expect(entry.errors).to include /invalid expression syntax/ - end - end - - context 'when specifying a valid changes policy' do - let(:config) { { changes: %w[some/* paths/**/*.rb] } } - - it 'is a correct configuraton' do - expect(entry).to be_valid - expect(entry.value).to eq(config) - end - end - - context 'when changes policy is invalid' do - let(:config) { { changes: [1, 2] } } - - it 'returns errors' do - expect(entry.errors).to include /changes should be an array of strings/ - end - end - - context 'when specifying unknown policy' do - let(:config) { { refs: ['master'], invalid: :something } } - - it 'returns error about invalid key' do - expect(entry.errors).to include /unknown keys: invalid/ - end - end - - context 'when policy is empty' do - let(:config) { {} } - - it 'is not a valid configuration' do - expect(entry.errors).to include /can't be blank/ - end - end - end - - context 'when policy strategy does not match' do - let(:config) { 'string strategy' } - - it 'returns information about errors' do - expect(entry.errors) - .to include /has to be either an array of conditions or a hash/ - end - end - describe '.default' do it 'does not have a default value' do expect(described_class.default).to be_nil diff --git a/spec/lib/gitlab/url_blocker_spec.rb b/spec/lib/gitlab/url_blocker_spec.rb index 39e0a17a307..62970bd8cb6 100644 --- a/spec/lib/gitlab/url_blocker_spec.rb +++ b/spec/lib/gitlab/url_blocker_spec.rb @@ -249,6 +249,27 @@ describe Gitlab::UrlBlocker do end end end + + context 'when ascii_only is true' do + it 'returns true for unicode domain' do + expect(described_class.blocked_url?('https://𝕘itⅼαƄ.com/foo/foo.bar', ascii_only: true)).to be true + end + + it 'returns true for unicode tld' do + expect(described_class.blocked_url?('https://gitlab.ᴄοm/foo/foo.bar', ascii_only: true)).to be true + end + + it 'returns true for unicode path' do + expect(described_class.blocked_url?('https://gitlab.com/𝒇οο/𝒇οο.Ƅαꮁ', ascii_only: true)).to be true + end + + it 'returns true for IDNA deviations' do + expect(described_class.blocked_url?('https://mißile.com/foo/foo.bar', ascii_only: true)).to be true + expect(described_class.blocked_url?('https://miςςile.com/foo/foo.bar', ascii_only: true)).to be true + expect(described_class.blocked_url?('https://gitlab.com/foo/foo.bar', ascii_only: true)).to be true + expect(described_class.blocked_url?('https://gitlab.com/foo/foo.bar', ascii_only: true)).to be true + end + end end describe '#validate_hostname!' do diff --git a/spec/migrations/populate_mr_metrics_with_events_data_spec.rb b/spec/migrations/populate_mr_metrics_with_events_data_spec.rb new file mode 100644 index 00000000000..291a52b904d --- /dev/null +++ b/spec/migrations/populate_mr_metrics_with_events_data_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20181204154019_populate_mr_metrics_with_events_data.rb') + +describe PopulateMrMetricsWithEventsData, :migration, :sidekiq do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:namespace) { namespaces.create(name: 'gitlab', path: 'gitlab-org') } + let(:project) { projects.create(namespace_id: namespace.id, name: 'foo') } + let(:merge_requests) { table(:merge_requests) } + + def create_merge_request(id) + params = { + id: id, + target_project_id: project.id, + target_branch: 'master', + source_project_id: project.id, + source_branch: 'mr name', + title: "mr name#{id}" + } + + merge_requests.create!(params) + end + + it 'correctly schedules background migrations' do + create_merge_request(1) + create_merge_request(2) + create_merge_request(3) + + stub_const("#{described_class.name}::BATCH_SIZE", 2) + + Sidekiq::Testing.fake! do + Timecop.freeze do + migrate! + + expect(described_class::MIGRATION) + .to be_scheduled_delayed_migration(8.minutes, 1, 2) + + expect(described_class::MIGRATION) + .to be_scheduled_delayed_migration(16.minutes, 3, 3) + + expect(BackgroundMigrationWorker.jobs.size).to eq(2) + end + end + end +end diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb index 097b1bb30dc..99d3ab41b97 100644 --- a/spec/models/members/project_member_spec.rb +++ b/spec/models/members/project_member_spec.rb @@ -11,10 +11,6 @@ describe ProjectMember do it { is_expected.to validate_inclusion_of(:access_level).in_array(Gitlab::Access.values) } end - describe 'modules' do - it { is_expected.to include_module(Gitlab::ShellAdapter) } - end - describe '.access_level_roles' do it 'returns Gitlab::Access.options' do expect(described_class.access_level_roles).to eq(Gitlab::Access.options) diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 1a7e40dfebb..5e63f14b720 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -1897,7 +1897,7 @@ describe Project do end end - describe '#latest_successful_builds_for' do + describe '#latest_successful_builds_for and #latest_successful_build_for' do def create_pipeline(status = 'success') create(:ci_pipeline, project: project, sha: project.commit.sha, @@ -1919,14 +1919,16 @@ describe Project do it 'gives the latest builds from latest pipeline' do pipeline1 = create_pipeline pipeline2 = create_pipeline - build1_p2 = create_build(pipeline2, 'test') create_build(pipeline1, 'test') create_build(pipeline1, 'test2') + build1_p2 = create_build(pipeline2, 'test') build2_p2 = create_build(pipeline2, 'test2') latest_builds = project.latest_successful_builds_for + single_build = project.latest_successful_build_for(build1_p2.name) expect(latest_builds).to contain_exactly(build2_p2, build1_p2) + expect(single_build).to eq(build1_p2) end end @@ -1936,16 +1938,22 @@ describe Project do context 'standalone pipeline' do it 'returns builds for ref for default_branch' do builds = project.latest_successful_builds_for + single_build = project.latest_successful_build_for(build.name) expect(builds).to contain_exactly(build) + expect(single_build).to eq(build) end - it 'returns empty relation if the build cannot be found' do + it 'returns empty relation if the build cannot be found for #latest_successful_builds_for' do builds = project.latest_successful_builds_for('TAIL') expect(builds).to be_kind_of(ActiveRecord::Relation) expect(builds).to be_empty end + + it 'returns exception if the build cannot be found for #latest_successful_build_for' do + expect { project.latest_successful_build_for(build.name, 'TAIL') }.to raise_error(ActiveRecord::RecordNotFound) + end end context 'with some pending pipeline' do @@ -1954,9 +1962,11 @@ describe Project do end it 'gives the latest build from latest pipeline' do - latest_build = project.latest_successful_builds_for + latest_builds = project.latest_successful_builds_for + last_single_build = project.latest_successful_build_for(build.name) - expect(latest_build).to contain_exactly(build) + expect(latest_builds).to contain_exactly(build) + expect(last_single_build).to eq(build) end end end diff --git a/spec/requests/api/jobs_spec.rb b/spec/requests/api/jobs_spec.rb index 8770365c893..cd4e480ca64 100644 --- a/spec/requests/api/jobs_spec.rb +++ b/spec/requests/api/jobs_spec.rb @@ -586,6 +586,136 @@ describe API::Jobs do end end + describe 'GET id/jobs/artifacts/:ref_name/raw/*artifact_path?job=name' do + context 'when job has artifacts' do + let(:job) { create(:ci_build, :artifacts, pipeline: pipeline, user: api_user) } + let(:artifact) { 'other_artifacts_0.1.2/another-subdirectory/banana_sample.gif' } + let(:visibility_level) { Gitlab::VisibilityLevel::PUBLIC } + let(:public_builds) { true } + + before do + stub_artifacts_object_storage + job.success + + project.update(visibility_level: visibility_level, + public_builds: public_builds) + + get_artifact_file(artifact) + end + + context 'when user is anonymous' do + let(:api_user) { nil } + + context 'when project is public' do + let(:visibility_level) { Gitlab::VisibilityLevel::PUBLIC } + let(:public_builds) { true } + + it 'allows to access artifacts' do + expect(response).to have_gitlab_http_status(200) + expect(response.headers.to_h) + .to include('Content-Type' => 'application/json', + 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/) + end + end + + context 'when project is public with builds access disabled' do + let(:visibility_level) { Gitlab::VisibilityLevel::PUBLIC } + let(:public_builds) { false } + + it 'rejects access to artifacts' do + expect(response).to have_gitlab_http_status(403) + expect(json_response).to have_key('message') + expect(response.headers.to_h) + .not_to include('Gitlab-Workhorse-Send-Data' => /artifacts-entry/) + end + end + + context 'when project is private' do + let(:visibility_level) { Gitlab::VisibilityLevel::PRIVATE } + let(:public_builds) { true } + + it 'rejects access and hides existence of artifacts' do + expect(response).to have_gitlab_http_status(404) + expect(json_response).to have_key('message') + expect(response.headers.to_h) + .not_to include('Gitlab-Workhorse-Send-Data' => /artifacts-entry/) + end + end + end + + context 'when user is authorized' do + let(:visibility_level) { Gitlab::VisibilityLevel::PRIVATE } + let(:public_builds) { true } + + it 'returns a specific artifact file for a valid path' do + expect(Gitlab::Workhorse) + .to receive(:send_artifacts_entry) + .and_call_original + + get_artifact_file(artifact) + + expect(response).to have_gitlab_http_status(200) + expect(response.headers.to_h) + .to include('Content-Type' => 'application/json', + 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/) + end + end + + context 'with branch name containing slash' do + before do + pipeline.reload + pipeline.update(ref: 'improve/awesome', + sha: project.commit('improve/awesome').sha) + end + + it 'returns a specific artifact file for a valid path' do + get_artifact_file(artifact, 'improve/awesome') + + expect(response).to have_gitlab_http_status(200) + expect(response.headers.to_h) + .to include('Content-Type' => 'application/json', + 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/) + end + end + + context 'non-existing job' do + shared_examples 'not found' do + it { expect(response).to have_gitlab_http_status(:not_found) } + end + + context 'has no such ref' do + before do + get_artifact_file('some/artifact', 'wrong-ref') + end + + it_behaves_like 'not found' + end + + context 'has no such job' do + before do + get_artifact_file('some/artifact', pipeline.ref, 'wrong-job-name') + end + + it_behaves_like 'not found' + end + end + end + + context 'when job does not have artifacts' do + let(:job) { create(:ci_build, pipeline: pipeline, user: api_user) } + + it 'does not return job artifact file' do + get_artifact_file('some/artifact') + + expect(response).to have_gitlab_http_status(404) + end + end + + def get_artifact_file(artifact_path, ref = pipeline.ref, job_name = job.name) + get api("/projects/#{project.id}/jobs/artifacts/#{ref}/raw/#{artifact_path}", api_user), job: job_name + end + end + describe 'GET /projects/:id/jobs/:job_id/trace' do before do get api("/projects/#{project.id}/jobs/#{job.id}/trace", api_user) diff --git a/spec/support/helpers/javascript_fixtures_helpers.rb b/spec/support/helpers/javascript_fixtures_helpers.rb index 086a345dca8..89c5ec7a718 100644 --- a/spec/support/helpers/javascript_fixtures_helpers.rb +++ b/spec/support/helpers/javascript_fixtures_helpers.rb @@ -6,6 +6,13 @@ module JavaScriptFixturesHelpers FIXTURE_PATH = 'spec/javascripts/fixtures'.freeze + def self.included(base) + base.around do |example| + # pick an arbitrary date from the past, so tests are not time dependent + Timecop.freeze(Time.utc(2015, 7, 3, 10)) { example.run } + end + end + # Public: Removes all fixture files from given directory # # directory_name - directory of the fixtures (relative to FIXTURE_PATH) diff --git a/spec/support/shared_examples/only_except_policy_examples.rb b/spec/support/shared_examples/only_except_policy_examples.rb new file mode 100644 index 00000000000..35240af1d74 --- /dev/null +++ b/spec/support/shared_examples/only_except_policy_examples.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +shared_examples 'correct only except policy' do + context 'when using simplified policy' do + describe 'validations' do + context 'when entry config value is valid' do + context 'when config is a branch or tag name' do + let(:config) { %w[master feature/branch] } + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + + describe '#value' do + it 'returns refs hash' do + expect(entry.value).to eq(refs: config) + end + end + end + + context 'when config is a regexp' do + let(:config) { ['/^issue-.*$/'] } + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context 'when config is a special keyword' do + let(:config) { %w[tags triggers branches] } + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + end + + context 'when entry value is not valid' do + let(:config) { [1] } + + describe '#errors' do + it 'saves errors' do + expect(entry.errors) + .to include /policy config should be an array of strings or regexps/ + end + end + end + end + end + + context 'when using complex policy' do + context 'when specifying refs policy' do + let(:config) { { refs: ['master'] } } + + it 'is a correct configuraton' do + expect(entry).to be_valid + expect(entry.value).to eq(refs: %w[master]) + end + end + + context 'when specifying kubernetes policy' do + let(:config) { { kubernetes: 'active' } } + + it 'is a correct configuraton' do + expect(entry).to be_valid + expect(entry.value).to eq(kubernetes: 'active') + end + end + + context 'when specifying invalid kubernetes policy' do + let(:config) { { kubernetes: 'something' } } + + it 'reports an error about invalid policy' do + expect(entry.errors).to include /unknown value: something/ + end + end + + context 'when specifying valid variables expressions policy' do + let(:config) { { variables: ['$VAR == null'] } } + + it 'is a correct configuraton' do + expect(entry).to be_valid + expect(entry.value).to eq(config) + end + end + + context 'when specifying variables expressions in invalid format' do + let(:config) { { variables: '$MY_VAR' } } + + it 'reports an error about invalid format' do + expect(entry.errors).to include /should be an array of strings/ + end + end + + context 'when specifying invalid variables expressions statement' do + let(:config) { { variables: ['$MY_VAR =='] } } + + it 'reports an error about invalid statement' do + expect(entry.errors).to include /invalid expression syntax/ + end + end + + context 'when specifying invalid variables expressions token' do + let(:config) { { variables: ['$MY_VAR == 123'] } } + + it 'reports an error about invalid expression' do + expect(entry.errors).to include /invalid expression syntax/ + end + end + + context 'when using invalid variables expressions regexp' do + let(:config) { { variables: ['$MY_VAR =~ /some ( thing/'] } } + + it 'reports an error about invalid expression' do + expect(entry.errors).to include /invalid expression syntax/ + end + end + + context 'when specifying a valid changes policy' do + let(:config) { { changes: %w[some/* paths/**/*.rb] } } + + it 'is a correct configuraton' do + expect(entry).to be_valid + expect(entry.value).to eq(config) + end + end + + context 'when changes policy is invalid' do + let(:config) { { changes: [1, 2] } } + + it 'returns errors' do + expect(entry.errors).to include /changes should be an array of strings/ + end + end + + context 'when specifying unknown policy' do + let(:config) { { refs: ['master'], invalid: :something } } + + it 'returns error about invalid key' do + expect(entry.errors).to include /unknown keys: invalid/ + end + end + + context 'when policy is empty' do + let(:config) { {} } + + it 'is not a valid configuration' do + expect(entry.errors).to include /can't be blank/ + end + end + end + + context 'when policy strategy does not match' do + let(:config) { 'string strategy' } + + it 'returns information about errors' do + expect(entry.errors) + .to include /has to be either an array of conditions or a hash/ + end + end +end diff --git a/spec/uploaders/namespace_file_uploader_spec.rb b/spec/uploaders/namespace_file_uploader_spec.rb index d09725ee4be..77401814194 100644 --- a/spec/uploaders/namespace_file_uploader_spec.rb +++ b/spec/uploaders/namespace_file_uploader_spec.rb @@ -1,18 +1,22 @@ require 'spec_helper' -IDENTIFIER = %r{\h+/\S+} - describe NamespaceFileUploader do let(:group) { build_stubbed(:group) } let(:uploader) { described_class.new(group) } let(:upload) { create(:upload, :namespace_upload, model: group) } + let(:identifier) { %r{\h+/\S+} } subject { uploader } - it_behaves_like 'builds correct paths', - store_dir: %r[uploads/-/system/namespace/\d+], - upload_path: IDENTIFIER, - absolute_path: %r[#{CarrierWave.root}/uploads/-/system/namespace/\d+/#{IDENTIFIER}] + it_behaves_like 'builds correct paths' do + let(:patterns) do + { + store_dir: %r[uploads/-/system/namespace/\d+], + upload_path: identifier, + absolute_path: %r[#{CarrierWave.root}/uploads/-/system/namespace/\d+/#{identifier}] + } + end + end context "object_store is REMOTE" do before do @@ -21,9 +25,14 @@ describe NamespaceFileUploader do include_context 'with storage', described_class::Store::REMOTE - it_behaves_like 'builds correct paths', - store_dir: %r[namespace/\d+/\h+], - upload_path: IDENTIFIER + it_behaves_like 'builds correct paths' do + let(:patterns) do + { + store_dir: %r[namespace/\d+/\h+], + upload_path: identifier + } + end + end end context '.base_dir' do diff --git a/spec/uploaders/personal_file_uploader_spec.rb b/spec/uploaders/personal_file_uploader_spec.rb index 7700b14ce6b..2896e9a112d 100644 --- a/spec/uploaders/personal_file_uploader_spec.rb +++ b/spec/uploaders/personal_file_uploader_spec.rb @@ -1,18 +1,22 @@ require 'spec_helper' -IDENTIFIER = %r{\h+/\S+} - describe PersonalFileUploader do let(:model) { create(:personal_snippet) } let(:uploader) { described_class.new(model) } let(:upload) { create(:upload, :personal_snippet_upload) } + let(:identifier) { %r{\h+/\S+} } subject { uploader } - it_behaves_like 'builds correct paths', - store_dir: %r[uploads/-/system/personal_snippet/\d+], - upload_path: IDENTIFIER, - absolute_path: %r[#{CarrierWave.root}/uploads/-/system/personal_snippet/\d+/#{IDENTIFIER}] + it_behaves_like 'builds correct paths' do + let(:patterns) do + { + store_dir: %r[uploads/-/system/personal_snippet/\d+], + upload_path: identifier, + absolute_path: %r[#{CarrierWave.root}/uploads/-/system/personal_snippet/\d+/#{identifier}] + } + end + end context "object_store is REMOTE" do before do @@ -21,9 +25,14 @@ describe PersonalFileUploader do include_context 'with storage', described_class::Store::REMOTE - it_behaves_like 'builds correct paths', - store_dir: %r[\d+/\h+], - upload_path: IDENTIFIER + it_behaves_like 'builds correct paths' do + let(:patterns) do + { + store_dir: %r[\d+/\h+], + upload_path: identifier + } + end + end end describe '#to_h' do diff --git a/spec/validators/url_validator_spec.rb b/spec/validators/url_validator_spec.rb index 082d09d3f16..f3f3386382f 100644 --- a/spec/validators/url_validator_spec.rb +++ b/spec/validators/url_validator_spec.rb @@ -143,4 +143,33 @@ describe UrlValidator do end end end + + context 'when ascii_only is' do + let(:url) { 'https://𝕘itⅼαƄ.com/foo/foo.bar'} + let(:validator) { described_class.new(attributes: [:link_url], ascii_only: ascii_only) } + + context 'true' do + let(:ascii_only) { true } + + it 'prevents unicode characters' do + badge.link_url = url + + subject + + expect(badge.errors.empty?).to be false + end + end + + context 'false (default)' do + let(:ascii_only) { false } + + it 'does not prevent unicode characters' do + badge.link_url = url + + subject + + expect(badge.errors.empty?).to be true + end + end + end end diff --git a/yarn.lock b/yarn.lock index ff1c7ec6f6f..041a974b193 100644 --- a/yarn.lock +++ b/yarn.lock @@ -616,6 +616,13 @@ lodash "^4.17.10" to-fast-properties "^2.0.0" +"@gitlab/csslab@^1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@gitlab/csslab/-/csslab-1.8.0.tgz#54a2457fdc80f006665f0e578a5532780954ccfa" + integrity sha512-RZylRElufH1kwsBQlIDaVcrcXMyD5IEGrU6ABUd8W3LG8/F9jJ4Y3Ys7EPTpK/qFJyx86AutTtFGRxRNlMx85w== + dependencies: + bootstrap "4.1.3" + "@gitlab/eslint-config@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@gitlab/eslint-config/-/eslint-config-1.2.0.tgz#115568a70edabbc024f1bc13ba1ba499a9ba05a9" @@ -634,10 +641,10 @@ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.42.0.tgz#54eb88606bb79b74373a3aa49d8c10557fb1fd7a" integrity sha512-mVm1kyV/M1fTbQcW8Edbk7BPT2syQf+ot9qwFzLFiFXAn3jXTi6xy+DS+0cgoTnglSUsXVl4qcVAQjt8YoOOOQ== -"@gitlab/ui@^1.14.0": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-1.14.0.tgz#f0fd7c0e6c45a36ab3be18d00e2908a8cb405f90" - integrity sha512-jkBTN8qO41A894kcLo6b/mfLIgL8YNn+ZzjgzEXaZ3PyeQ3mKBdrBoSYkzH556qviroBvk/+3yyZz96VUo08qQ== +"@gitlab/ui@^1.15.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-1.15.0.tgz#288e189cb99de354aeb4598f9ac8cced5f47e139" + integrity sha512-Aiv/WABr8lBVJk0eoanSoO07Lr5Nnvuq82IjDnNzcw9enB1DAKvlstC2r9iiMfg1pVgV/uLdDeRFqH9eI1X4Rg== dependencies: babel-standalone "^6.26.0" bootstrap-vue "^2.0.0-rc.11" |