diff options
Diffstat (limited to 'app/assets/javascripts')
71 files changed, 1174 insertions, 294 deletions
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 743f11625bc..aaab217964c 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -1,4 +1,4 @@ -/* eslint-disable class-methods-use-this */ +/* eslint-disable class-methods-use-this, @gitlab/i18n/no-non-i18n-strings */ import $ from 'jquery'; import _ from 'underscore'; diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index da82b52330a..d4f4df3ad75 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -1,4 +1,5 @@ const notImplemented = () => { + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ throw new Error('Not implemented!'); }; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 77ba68be07e..09eb8bb9b98 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -1,6 +1,7 @@ import * as mutationTypes from './mutation_types'; const notImplemented = () => { + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ throw new Error('Not implemented!'); }; diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js index b62ec8a651b..9263e9b27e4 100644 --- a/app/assets/javascripts/contextual_sidebar.js +++ b/app/assets/javascripts/contextual_sidebar.js @@ -78,7 +78,7 @@ export default class ContextualSidebar { const dbp = ContextualSidebar.isDesktopBreakpoint(); if (this.$sidebar.length) { - this.$sidebar.toggleClass('sidebar-collapsed-desktop', collapsed); + this.$sidebar.toggleClass(`sidebar-collapsed-desktop ${SIDEBAR_COLLAPSED_CLASS}`, collapsed); this.$sidebar.toggleClass('sidebar-expanded-mobile', !dbp ? !collapsed : false); this.$page.toggleClass( 'page-with-icon-sidebar', diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.vue deleted file mode 100644 index 6c256fa6736..00000000000 --- a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.vue +++ /dev/null @@ -1,59 +0,0 @@ -<script> -import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; -import iconCommit from '../svg/icon_commit.svg'; -import limitWarning from './limit_warning_component.vue'; -import totalTime from './total_time_component.vue'; - -export default { - components: { - userAvatarImage, - totalTime, - limitWarning, - }, - props: { - items: { - type: Array, - default: () => [], - }, - stage: { - type: Object, - default: () => ({}), - }, - }, - computed: { - iconCommit() { - return iconCommit; - }, - }, -}; -</script> -<template> - <div> - <div class="events-description"> - {{ stage.description }} - <limit-warning :count="items.length" /> - </div> - <ul class="stage-event-list"> - <li v-for="(commit, i) in items" :key="i" class="stage-event-item"> - <div class="item-details item-conmmit-component"> - <!-- FIXME: Pass an alt attribute here for accessibility --> - <user-avatar-image :img-src="commit.author.avatarUrl" /> - <h5 class="item-title commit-title"> - <a :href="commit.commitUrl"> {{ commit.title }} </a> - </h5> - <span> - {{ s__('FirstPushedBy|First') }} <span class="commit-icon" v-html="iconCommit"> </span> - <a :href="commit.commitUrl" class="commit-hash-link commit-sha">{{ - commit.shortSha - }}</a> - {{ s__('FirstPushedBy|pushed by') }} - <a :href="commit.author.webUrl" class="commit-author-link"> - {{ commit.author.name }} - </a> - </span> - </div> - <div class="item-time"><total-time :time="commit.totalTime" /></div> - </li> - </ul> - </div> -</template> diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index 3f0a9f2602c..b56e08175cc 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -5,7 +5,6 @@ import Flash from '../flash'; import Translate from '../vue_shared/translate'; import banner from './components/banner.vue'; import stageCodeComponent from './components/stage_code_component.vue'; -import stagePlanComponent from './components/stage_plan_component.vue'; import stageComponent from './components/stage_component.vue'; import stageReviewComponent from './components/stage_review_component.vue'; import stageStagingComponent from './components/stage_staging_component.vue'; @@ -26,7 +25,7 @@ export default () => { components: { banner, 'stage-issue-component': stageComponent, - 'stage-plan-component': stagePlanComponent, + 'stage-plan-component': stageComponent, 'stage-code-component': stageCodeComponent, 'stage-test-component': stageTestComponent, 'stage-review-component': stageReviewComponent, diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 11d6672cacf..81da0754752 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -69,6 +69,16 @@ export default { required: false, default: false, }, + dismissEndpoint: { + type: String, + required: false, + default: '', + }, + showSuggestPopover: { + type: Boolean, + required: false, + default: false, + }, }, data() { const treeWidth = @@ -141,7 +151,12 @@ export default { showTreeList: 'adjustView', }, mounted() { - this.setBaseConfig({ endpoint: this.endpoint, projectPath: this.projectPath }); + this.setBaseConfig({ + endpoint: this.endpoint, + projectPath: this.projectPath, + dismissEndpoint: this.dismissEndpoint, + showSuggestPopover: this.showSuggestPopover, + }); if (this.shouldShow) { this.fetchData(); diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index c209b857652..da0cdbe467b 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -42,6 +42,7 @@ export default { noteableData: state => state.notes.noteableData, diffViewType: state => state.diffs.diffViewType, }), + ...mapState('diffs', ['showSuggestPopover']), ...mapGetters('diffs', ['getDiffFileByHash']), ...mapGetters([ 'isLoggedIn', @@ -80,7 +81,12 @@ export default { } }, methods: { - ...mapActions('diffs', ['cancelCommentForm', 'assignDiscussionsToDiff', 'saveDiffDiscussion']), + ...mapActions('diffs', [ + 'cancelCommentForm', + 'assignDiscussionsToDiff', + 'saveDiffDiscussion', + 'setSuggestPopoverDismissed', + ]), handleCancelCommentForm(shouldConfirm, isDirty) { if (shouldConfirm && isDirty) { const msg = s__('Notes|Are you sure you want to cancel creating this comment?'); @@ -125,11 +131,13 @@ export default { :line="line" :help-page-path="helpPagePath" :diff-file="diffFile" + :show-suggest-popover="showSuggestPopover" save-button-title="Comment" class="diff-comment-form" @handleFormUpdateAddToReview="addToReview" @cancelForm="handleCancelCommentForm" @handleFormUpdate="handleSaveNote" + @handleSuggestDismissed="setSuggestPopoverDismissed" /> </div> </template> diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 1d897bca1dd..1e57e9b8a30 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -72,6 +72,8 @@ export default function initDiffsApp(store) { currentUser: JSON.parse(dataset.currentUserData) || {}, changesEmptyStateIllustration: dataset.changesEmptyStateIllustration, isFluidLayout: parseBoolean(dataset.isFluidLayout), + dismissEndpoint: dataset.dismissEndpoint, + showSuggestPopover: parseBoolean(dataset.showSuggestPopover), }; }, computed: { @@ -99,6 +101,8 @@ export default function initDiffsApp(store) { shouldShow: this.activeTab === 'diffs', changesEmptyStateIllustration: this.changesEmptyStateIllustration, isFluidLayout: this.isFluidLayout, + dismissEndpoint: this.dismissEndpoint, + showSuggestPopover: this.showSuggestPopover, }, }); }, diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 479afc50113..88d7b4bba63 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -36,8 +36,8 @@ import { import { diffViewerModes } from '~/ide/constants'; export const setBaseConfig = ({ commit }, options) => { - const { endpoint, projectPath } = options; - commit(types.SET_BASE_CONFIG, { endpoint, projectPath }); + const { endpoint, projectPath, dismissEndpoint, showSuggestPopover } = options; + commit(types.SET_BASE_CONFIG, { endpoint, projectPath, dismissEndpoint, showSuggestPopover }); }; export const fetchDiffFiles = ({ state, commit }) => { @@ -455,5 +455,17 @@ export const toggleFullDiff = ({ dispatch, getters, state }, filePath) => { export const setFileCollapsed = ({ commit }, { filePath, collapsed }) => commit(types.SET_FILE_COLLAPSED, { filePath, collapsed }); +export const setSuggestPopoverDismissed = ({ commit, state }) => + axios + .post(state.dismissEndpoint, { + feature_name: 'suggest_popover_dismissed', + }) + .then(() => { + commit(types.SET_SHOW_SUGGEST_POPOVER); + }) + .catch(() => { + createFlash(s__('MergeRequest|Error dismissing suggestion popover. Please try again.')); + }); + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index cf4dd93dbfb..6821c8445ea 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -28,4 +28,6 @@ export default () => ({ renderTreeList: true, showWhitespace: true, fileFinderVisible: false, + dismissEndpoint: '', + showSuggestPopover: true, }); diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index 6bb24c97139..8d6111da500 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -33,3 +33,5 @@ export const SET_HIDDEN_VIEW_DIFF_FILE_LINES = 'SET_HIDDEN_VIEW_DIFF_FILE_LINES' export const SET_CURRENT_VIEW_DIFF_FILE_LINES = 'SET_CURRENT_VIEW_DIFF_FILE_LINES'; export const ADD_CURRENT_VIEW_DIFF_FILE_LINES = 'ADD_CURRENT_VIEW_DIFF_FILE_LINES'; export const TOGGLE_DIFF_FILE_RENDERING_MORE = 'TOGGLE_DIFF_FILE_RENDERING_MORE'; + +export const SET_SHOW_SUGGEST_POPOVER = 'SET_SHOW_SUGGEST_POPOVER'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 67bc1724738..00181a63c43 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -11,8 +11,8 @@ import * as types from './mutation_types'; export default { [types.SET_BASE_CONFIG](state, options) { - const { endpoint, projectPath } = options; - Object.assign(state, { endpoint, projectPath }); + const { endpoint, projectPath, dismissEndpoint, showSuggestPopover } = options; + Object.assign(state, { endpoint, projectPath, dismissEndpoint, showSuggestPopover }); }, [types.SET_LOADING](state, isLoading) { @@ -302,4 +302,7 @@ export default { file.renderingLines = !file.renderingLines; }, + [types.SET_SHOW_SUGGEST_POPOVER](state) { + state.showSuggestPopover = false; + }, }; diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js index be867a3838d..891086b4142 100644 --- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -8,9 +8,19 @@ import DropdownUtils from './dropdown_utils'; import { mergeUrlParams } from '../lib/utils/url_utility'; export default class AvailableDropdownMappings { - constructor(container, baseEndpoint, groupsOnly, includeAncestorGroups, includeDescendantGroups) { + constructor( + container, + baseEndpoint, + labelsEndpoint, + milestonesEndpoint, + groupsOnly, + includeAncestorGroups, + includeDescendantGroups, + ) { this.container = container; this.baseEndpoint = baseEndpoint; + this.labelsEndpoint = labelsEndpoint; + this.milestonesEndpoint = milestonesEndpoint; this.groupsOnly = groupsOnly; this.includeAncestorGroups = includeAncestorGroups; this.includeDescendantGroups = includeDescendantGroups; @@ -117,11 +127,11 @@ export default class AvailableDropdownMappings { } getMilestoneEndpoint() { - return `${this.baseEndpoint}/milestones.json`; + return `${this.milestonesEndpoint}.json`; } getLabelsEndpoint() { - let endpoint = `${this.baseEndpoint}/labels.json?`; + let endpoint = `${this.labelsEndpoint}.json?`; if (this.groupsOnly) { endpoint = `${endpoint}only_group_labels=true&`; diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index cb0a84b490b..1cbfd7f9bb9 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -9,6 +9,8 @@ import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; export default class FilteredSearchDropdownManager { constructor({ baseEndpoint = '', + labelsEndpoint = '', + milestonesEndpoint = '', tokenizer, page, isGroup, @@ -18,6 +20,8 @@ export default class FilteredSearchDropdownManager { }) { this.container = FilteredSearchContainer.container; this.baseEndpoint = baseEndpoint.replace(/\/$/, ''); + this.labelsEndpoint = labelsEndpoint.replace(/\/$/, ''); + this.milestonesEndpoint = milestonesEndpoint.replace(/\/$/, ''); this.tokenizer = tokenizer; this.filteredSearchTokenKeys = filteredSearchTokenKeys || FilteredSearchTokenKeys; this.filteredSearchInput = this.container.querySelector('.filtered-search'); @@ -48,6 +52,8 @@ export default class FilteredSearchDropdownManager { const availableMappings = new AvailableDropdownMappings( this.container, this.baseEndpoint, + this.labelsEndpoint, + this.milestonesEndpoint, this.groupsOnly, this.includeAncestorGroups, this.includeDescendantGroups, diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 78fbb3696cc..450e0725f2e 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -86,6 +86,8 @@ export default class FilteredSearchManager { this.tokenizer = FilteredSearchTokenizer; this.dropdownManager = new FilteredSearchDropdownManager({ baseEndpoint: this.filteredSearchInput.getAttribute('data-base-endpoint') || '', + labelsEndpoint: this.filteredSearchInput.getAttribute('data-labels-endpoint') || '', + milestonesEndpoint: this.filteredSearchInput.getAttribute('data-milestones-endpoint') || '', tokenizer: this.tokenizer, page: this.page, isGroup: this.isGroup, diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js index 38327472cb3..a54b445fb0a 100644 --- a/app/assets/javascripts/filtered_search/visual_token_value.js +++ b/app/assets/javascripts/filtered_search/visual_token_value.js @@ -56,13 +56,13 @@ export default class VisualTokenValue { updateLabelTokenColor(tokenValueContainer) { const { tokenValue } = this; const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search'); - const { baseEndpoint } = filteredSearchInput.dataset; - const labelsEndpoint = FilteredSearchVisualTokens.getEndpointWithQueryParams( - `${baseEndpoint}/labels.json`, + const { labelsEndpoint } = filteredSearchInput.dataset; + const labelsEndpointWithParams = FilteredSearchVisualTokens.getEndpointWithQueryParams( + `${labelsEndpoint}.json`, filteredSearchInput.dataset.endpointQueryParams, ); - return AjaxCache.retrieve(labelsEndpoint) + return AjaxCache.retrieve(labelsEndpointWithParams) .then(labels => { const matchingLabel = (labels || []).find( label => `~${DropdownUtils.getEscapedText(label.title)}` === tokenValue, diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index e41b1530226..363a8f43033 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -146,7 +146,7 @@ export default { </div> <component :is="rightPaneComponent" v-if="currentProjectId" /> </div> - <ide-status-bar :file="activeFile" /> + <ide-status-bar /> <new-modal /> </article> </template> diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index ce577ae85b0..206b8341aad 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -1,5 +1,6 @@ <script> import { mapActions, mapState, mapGetters } from 'vuex'; +import IdeStatusList from 'ee_else_ce/ide/components/ide_status_list.vue'; import icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import timeAgoMixin from '~/vue_shared/mixins/timeago'; @@ -12,18 +13,12 @@ export default { icon, userAvatarImage, CiIcon, + IdeStatusList, }, directives: { tooltip, }, mixins: [timeAgoMixin], - props: { - file: { - type: Object, - required: false, - default: null, - }, - }, data() { return { lastCommitFormatedAge: null, @@ -125,11 +120,6 @@ export default { >{{ lastCommitFormatedAge }}</time > </div> - <div v-if="file" class="ide-status-file">{{ file.name }}</div> - <div v-if="file" class="ide-status-file">{{ file.eol }}</div> - <div v-if="file && !file.binary" class="ide-status-file"> - {{ file.editorRow }}:{{ file.editorColumn }} - </div> - <div v-if="file" class="ide-status-file">{{ file.fileLanguage }}</div> + <ide-status-list class="ml-auto" /> </footer> </template> diff --git a/app/assets/javascripts/ide/components/ide_status_list.vue b/app/assets/javascripts/ide/components/ide_status_list.vue new file mode 100644 index 00000000000..364e3f081a1 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_status_list.vue @@ -0,0 +1,23 @@ +<script> +import { mapGetters } from 'vuex'; + +export default { + computed: { + ...mapGetters(['activeFile']), + }, +}; +</script> + +<template> + <div class="ide-status-list d-flex"> + <template v-if="activeFile"> + <div class="ide-status-file">{{ activeFile.name }}</div> + <div class="ide-status-file">{{ activeFile.eol }}</div> + <div v-if="!activeFile.binary" class="ide-status-file"> + {{ activeFile.editorRow }}:{{ activeFile.editorColumn }} + </div> + <div class="ide-status-file">{{ activeFile.fileLanguage }}</div> + </template> + <slot></slot> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index e88ca4747c5..de2a9664cde 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -11,6 +11,7 @@ import titleComponent from './title.vue'; import descriptionComponent from './description.vue'; import editedComponent from './edited.vue'; import formComponent from './form.vue'; +import PinnedLinks from './pinned_links.vue'; import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor'; export default { @@ -19,6 +20,7 @@ export default { titleComponent, editedComponent, formComponent, + PinnedLinks, }, mixins: [recaptchaModalImplementor], props: { @@ -340,6 +342,7 @@ export default { :title-text="state.titleText" :show-inline-edit-button="showInlineEditButton" /> + <pinned-links :description-html="state.descriptionHtml" /> <description-component v-if="state.descriptionHtml" :can-update="canUpdate" diff --git a/app/assets/javascripts/issue_show/components/pinned_links.vue b/app/assets/javascripts/issue_show/components/pinned_links.vue new file mode 100644 index 00000000000..7a54b26bc2b --- /dev/null +++ b/app/assets/javascripts/issue_show/components/pinned_links.vue @@ -0,0 +1,52 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + Icon, + GlLink, + }, + props: { + descriptionHtml: { + type: String, + required: true, + }, + }, + computed: { + linksInDescription() { + const el = document.createElement('div'); + el.innerHTML = this.descriptionHtml; + return [...el.querySelectorAll('a')].map(a => a.href); + }, + // Detect links matching the following formats: + // Zoom Start links: https://zoom.us/s/<meeting-id> + // Zoom Join links: https://zoom.us/j/<meeting-id> + // Personal Zoom links: https://zoom.us/my/<meeting-id> + // Vanity Zoom links: https://gitlab.zoom.us/j/<meeting-id> (also /s and /my) + zoomHref() { + const zoomRegex = /^https:\/\/([\w\d-]+\.)?zoom\.us\/(s|j|my)\/.+/; + return this.linksInDescription.reduce((acc, currentLink) => { + let lastLink = acc; + if (zoomRegex.test(currentLink)) { + lastLink = currentLink; + } + return lastLink; + }, ''); + }, + }, +}; +</script> + +<template> + <div v-if="zoomHref" class="border-bottom mb-3 mt-n2"> + <gl-link + :href="zoomHref" + target="_blank" + class="btn btn-inverted btn-secondary btn-sm text-dark mb-3" + > + <icon name="brand-zoom" :size="14" /> + <strong class="vertical-align-top">{{ __('Join Zoom meeting') }}</strong> + </gl-link> + </div> +</template> diff --git a/app/assets/javascripts/jobs/components/job_log.vue b/app/assets/javascripts/jobs/components/job_log.vue index 92e20e92d66..d611b370ab9 100644 --- a/app/assets/javascripts/jobs/components/job_log.vue +++ b/app/assets/javascripts/jobs/components/job_log.vue @@ -17,10 +17,19 @@ export default { ...mapState(['isScrolledToBottomBeforeReceivingTrace']), }, updated() { - this.$nextTick(() => this.handleScrollDown()); + this.$nextTick(() => { + this.handleScrollDown(); + this.handleCollapsibleRows(); + }); }, mounted() { - this.$nextTick(() => this.handleScrollDown()); + this.$nextTick(() => { + this.handleScrollDown(); + this.handleCollapsibleRows(); + }); + }, + destroyed() { + this.removeEventListener(); }, methods: { ...mapActions(['scrollBottom']), @@ -38,21 +47,45 @@ export default { }, 0); } }, + removeEventListener() { + this.$el + .querySelectorAll('.js-section-start') + .forEach(el => el.removeEventListener('click', this.handleSectionClick)); + }, + /** + * The collapsible rows are sent in HTML from the backend + * We need tos add a onclick handler for the divs that match `.js-section-start` + * + */ + handleCollapsibleRows() { + this.$el + .querySelectorAll('.js-section-start') + .forEach(el => el.addEventListener('click', this.handleSectionClick)); + }, + /** + * On click, we toggle the hidden class of + * all the rows that match the `data-section` selector + */ + handleSectionClick(evt) { + const clickedArrow = evt.currentTarget; + // toggle the arrow class + clickedArrow.classList.toggle('fa-caret-right'); + clickedArrow.classList.toggle('fa-caret-down'); + + const { section } = clickedArrow.dataset; + const sibilings = this.$el.querySelectorAll(`.js-s-${section}:not(.js-section-header)`); + + sibilings.forEach(row => row.classList.toggle('hidden')); + }, }, }; </script> <template> <pre class="js-build-trace build-trace qa-build-trace"> - <code - class="bash" - v-html="trace" - > + <code class="bash" v-html="trace"> </code> - <div - v-if="!isComplete" - class="js-log-animation build-loader-animation" - > + <div v-if="!isComplete" class="js-log-animation build-loader-animation"> <div class="dot"></div> <div class="dot"></div> <div class="dot"></div> diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue index cb073a9b04d..6e92b599b0a 100644 --- a/app/assets/javascripts/jobs/components/stages_dropdown.vue +++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue @@ -2,7 +2,6 @@ import _ from 'underscore'; import { GlLink } from '@gitlab/ui'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import PipelineLink from '~/vue_shared/components/ci_pipeline_link.vue'; import Icon from '~/vue_shared/components/icon.vue'; export default { @@ -10,7 +9,6 @@ export default { CiIcon, Icon, GlLink, - PipelineLink, }, props: { pipeline: { @@ -50,12 +48,9 @@ export default { <ci-icon :status="pipeline.details.status" class="vertical-align-middle" /> <span class="font-weight-bold">{{ s__('Job|Pipeline') }}</span> - <pipeline-link - :href="pipeline.path" - :pipeline-id="pipeline.id" - :pipeline-iid="pipeline.iid" - class="js-pipeline-path link-commit qa-pipeline-path" - /> + <gl-link :href="pipeline.path" class="js-pipeline-path link-commit qa-pipeline-path" + >#{{ pipeline.id }}</gl-link + > <template v-if="hasRef"> {{ s__('Job|for') }} diff --git a/app/assets/javascripts/lib/utils/autosave.js b/app/assets/javascripts/lib/utils/autosave.js index 023c336db02..37896626053 100644 --- a/app/assets/javascripts/lib/utils/autosave.js +++ b/app/assets/javascripts/lib/utils/autosave.js @@ -29,4 +29,5 @@ export const updateDraft = (autosaveKey, text) => { }; export const getDiscussionReplyKey = (noteableType, discussionId) => + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ ['Note', capitalizeFirstCharacter(noteableType), discussionId, 'Reply'].join('/'); diff --git a/app/assets/javascripts/lib/utils/invalid_url.js b/app/assets/javascripts/lib/utils/invalid_url.js new file mode 100644 index 00000000000..481bd059fc9 --- /dev/null +++ b/app/assets/javascripts/lib/utils/invalid_url.js @@ -0,0 +1,6 @@ +/** + * Invalid URL that ensures we don't make a network request + * Can be used as a default value for URLs. Using an empty + * string can still result in request being made to the current page + */ +export default 'https://invalid'; diff --git a/app/assets/javascripts/lib/utils/notify.js b/app/assets/javascripts/lib/utils/notify.js index d93873e0214..e7f6255e5f1 100644 --- a/app/assets/javascripts/lib/utils/notify.js +++ b/app/assets/javascripts/lib/utils/notify.js @@ -12,6 +12,7 @@ function notificationGranted(message, opts, onclick) { } function notifyPermissions() { + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ if ('Notification' in window) { return Notification.requestPermission(); } @@ -24,6 +25,7 @@ function notifyMe(message, body, icon, onclick) { icon: icon, }; // Let's check if the browser supports notifications + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ if (!('Notification' in window)) { // do nothing } else if (Notification.permission === 'granted') { diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index e5cf43e8289..b6868e63716 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -147,14 +147,14 @@ export default class MergeRequestTabs { e.stopImmediatePropagation(); e.preventDefault(); - const { action } = e.currentTarget.dataset; + const { action } = e.currentTarget.dataset || {}; - if (action) { - const href = e.currentTarget.getAttribute('href'); - this.tabShown(action, href); - } else if (isMetaClick(e)) { + if (isMetaClick(e)) { const targetLink = e.currentTarget.getAttribute('href'); window.open(targetLink, '_blank'); + } else if (action) { + const href = e.currentTarget.getAttribute('href'); + this.tabShown(action, href); } } } diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue index ed504246ef3..9de4e96e4da 100644 --- a/app/assets/javascripts/monitoring/components/charts/area.vue +++ b/app/assets/javascripts/monitoring/components/charts/area.vue @@ -227,6 +227,7 @@ export default { [this.primaryColor] = chart.getOption().color; }, onResize() { + if (!this.$refs.areaChart) return; const { width } = this.$refs.areaChart.$el.getBoundingClientRect(); this.width = width; }, diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index b0de142d9d8..0a652329dfe 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -6,6 +6,7 @@ import { s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import '~/vue_shared/mixins/is_ee'; import { getParameterValues } from '~/lib/utils/url_utility'; +import invalidUrl from '~/lib/utils/invalid_url'; import MonitorAreaChart from './charts/area.vue'; import GraphGroup from './graph_group.vue'; import EmptyState from './empty_state.vue'; @@ -69,7 +70,7 @@ export default { type: String, required: true, }, - deploymentEndpoint: { + deploymentsEndpoint: { type: String, required: false, default: null, @@ -111,6 +112,11 @@ export default { type: String, required: true, }, + dashboardEndpoint: { + type: String, + required: false, + default: invalidUrl, + }, }, data() { return { @@ -131,13 +137,19 @@ export default { 'showEmptyState', 'environments', 'deploymentData', + 'metricsWithData', + 'useDashboardEndpoint', ]), + groupsWithData() { + return this.groups.filter(group => this.chartsWithData(group.metrics).length > 0); + }, }, created() { this.setEndpoints({ metricsEndpoint: this.metricsEndpoint, environmentsEndpoint: this.environmentsEndpoint, - deploymentsEndpoint: this.deploymentEndpoint, + deploymentsEndpoint: this.deploymentsEndpoint, + dashboardEndpoint: this.dashboardEndpoint, }); this.timeWindows = timeWindows; @@ -175,7 +187,16 @@ export default { 'fetchData', 'setGettingStartedEmptyState', 'setEndpoints', + 'setDashboardEnabled', ]), + chartsWithData(charts) { + if (!this.useDashboardEndpoint) { + return charts; + } + return charts.filter(chart => + chart.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)), + ); + }, getGraphAlerts(queries) { if (!this.allAlerts) return {}; const metricIdsForChart = queries.map(q => q.metricId); @@ -259,9 +280,8 @@ export default { <gl-button v-gl-modal-directive="$options.addMetric.modalId" class="js-add-metric-button text-success border-success" + >{{ $options.addMetric.title }}</gl-button > - {{ $options.addMetric.title }} - </gl-button> <gl-modal ref="addMetricModal" :modal-id="$options.addMetric.modalId" @@ -275,16 +295,13 @@ export default { /> </form> <div slot="modal-footer"> - <gl-button @click="hideAddMetricModal"> - {{ __('Cancel') }} - </gl-button> + <gl-button @click="hideAddMetricModal">{{ __('Cancel') }}</gl-button> <gl-button :disabled="!formIsValid" variant="success" @click="submitCustomMetricsForm" + >{{ __('Save changes') }}</gl-button > - {{ __('Save changes') }} - </gl-button> </div> </gl-modal> </div> @@ -301,13 +318,13 @@ export default { </div> </div> <graph-group - v-for="(groupData, index) in groups" + v-for="(groupData, index) in groupsWithData" :key="index" :name="groupData.group" :show-panels="showPanels" > <monitor-area-chart - v-for="(graphData, graphIndex) in groupData.metrics" + v-for="(graphData, graphIndex) in chartsWithData(groupData.metrics)" :key="graphIndex" :graph-data="graphData" :deployment-data="deploymentData" diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index 62c0f44c1e6..1d33537b3b2 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -7,6 +7,11 @@ export default (props = {}) => { const el = document.getElementById('prometheus-graphs'); if (el && el.dataset) { + store.dispatch( + 'monitoringDashboard/setDashboardEnabled', + gon.features.environmentMetricsUsePrometheusEndpoint, + ); + // eslint-disable-next-line no-new new Vue({ el, diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 63c23e8449d..f41e215cb5d 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -35,6 +35,21 @@ export const setEndpoints = ({ commit }, endpoints) => { commit(types.SET_ENDPOINTS, endpoints); }; +export const setDashboardEnabled = ({ commit }, enabled) => { + commit(types.SET_DASHBOARD_ENABLED, enabled); +}; + +export const requestMetricsDashboard = ({ commit }) => { + commit(types.REQUEST_METRICS_DATA); +}; +export const receiveMetricsDashboardSuccess = ({ commit, dispatch }, { response, params }) => { + commit(types.RECEIVE_METRICS_DATA_SUCCESS, response.dashboard.panel_groups); + dispatch('fetchPrometheusMetrics', params); +}; +export const receiveMetricsDashboardFailure = ({ commit }, error) => { + commit(types.RECEIVE_METRICS_DATA_FAILURE, error); +}; + export const requestMetricsData = ({ commit }) => commit(types.REQUEST_METRICS_DATA); export const receiveMetricsDataSuccess = ({ commit }, data) => commit(types.RECEIVE_METRICS_DATA_SUCCESS, data); @@ -56,6 +71,10 @@ export const fetchData = ({ dispatch }, params) => { }; export const fetchMetricsData = ({ state, dispatch }, params) => { + if (state.useDashboardEndpoint) { + return dispatch('fetchDashboard', params); + } + dispatch('requestMetricsData'); return backOffRequest(() => axios.get(state.metricsEndpoint, { params })) @@ -73,11 +92,82 @@ export const fetchMetricsData = ({ state, dispatch }, params) => { }); }; +export const fetchDashboard = ({ state, dispatch }, params) => { + dispatch('requestMetricsDashboard'); + + return axios + .get(state.dashboardEndpoint, { params }) + .then(resp => resp.data) + .then(response => { + dispatch('receiveMetricsDashboardSuccess', { response, params }); + }) + .catch(error => { + dispatch('receiveMetricsDashboardFailure', error); + createFlash(s__('Metrics|There was an error while retrieving metrics')); + }); +}; + +function fetchPrometheusResult(prometheusEndpoint, params) { + return backOffRequest(() => axios.get(prometheusEndpoint, { params })) + .then(res => res.data) + .then(response => { + if (response.status === 'error') { + throw new Error(response.error); + } + + return response.data.result; + }); +} + +/** + * Returns list of metrics in data.result + * {"status":"success", "data":{"resultType":"matrix","result":[]}} + * + * @param {metric} metric + */ +export const fetchPrometheusMetric = ({ commit }, { metric, params }) => { + const { start, end } = params; + const timeDiff = end - start; + + const minStep = 60; + const queryDataPoints = 600; + const step = Math.max(minStep, Math.ceil(timeDiff / queryDataPoints)); + + const queryParams = { + start, + end, + step, + }; + + return fetchPrometheusResult(metric.prometheus_endpoint_path, queryParams).then(result => { + commit(types.SET_QUERY_RESULT, { metricId: metric.metric_id, result }); + }); +}; + +export const fetchPrometheusMetrics = ({ state, commit, dispatch }, params) => { + commit(types.REQUEST_METRICS_DATA); + + const promises = []; + state.groups.forEach(group => { + group.panels.forEach(panel => { + panel.metrics.forEach(metric => { + promises.push(dispatch('fetchPrometheusMetric', { metric, params })); + }); + }); + }); + + return Promise.all(promises).then(() => { + if (state.metricsWithData.length === 0) { + commit(types.SET_NO_DATA_EMPTY_STATE); + } + }); +}; + export const fetchDeploymentsData = ({ state, dispatch }) => { - if (!state.deploymentEndpoint) { + if (!state.deploymentsEndpoint) { return Promise.resolve([]); } - return backOffRequest(() => axios.get(state.deploymentEndpoint)) + return backOffRequest(() => axios.get(state.deploymentsEndpoint)) .then(resp => resp.data) .then(response => { if (!response || !response.deployments) { diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index 3fd9e07fa8b..63894e83362 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -7,6 +7,9 @@ export const RECEIVE_DEPLOYMENTS_DATA_FAILURE = 'RECEIVE_DEPLOYMENTS_DATA_FAILUR export const REQUEST_ENVIRONMENTS_DATA = 'REQUEST_ENVIRONMENTS_DATA'; export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCCESS'; export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAILURE'; +export const SET_QUERY_RESULT = 'SET_QUERY_RESULT'; export const SET_TIME_WINDOW = 'SET_TIME_WINDOW'; +export const SET_DASHBOARD_ENABLED = 'SET_DASHBOARD_ENABLED'; export const SET_ENDPOINTS = 'SET_ENDPOINTS'; export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE'; +export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index c1779333d75..d4b816e2717 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -1,5 +1,6 @@ +import Vue from 'vue'; import * as types from './mutation_types'; -import { normalizeMetrics, sortMetrics } from './utils'; +import { normalizeMetrics, sortMetrics, normalizeQueryResult } from './utils'; export default { [types.REQUEST_METRICS_DATA](state) { @@ -7,10 +8,24 @@ export default { state.showEmptyState = true; }, [types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) { - state.groups = groupData.map(group => ({ - ...group, - metrics: normalizeMetrics(sortMetrics(group.metrics)), - })); + state.groups = groupData.map(group => { + let { metrics } = group; + + // for backwards compatibility, and to limit Vue template changes: + // for each group alias panels to metrics + // for each panel alias metrics to queries + if (state.useDashboardEndpoint) { + metrics = group.panels.map(panel => ({ + ...panel, + queries: panel.metrics, + })); + } + + return { + ...group, + metrics: normalizeMetrics(sortMetrics(metrics)), + }; + }); if (!state.groups.length) { state.emptyState = 'noData'; @@ -34,12 +49,40 @@ export default { [types.RECEIVE_ENVIRONMENTS_DATA_FAILURE](state) { state.environments = []; }, + [types.SET_QUERY_RESULT](state, { metricId, result }) { + if (!metricId || !result || result.length === 0) { + return; + } + + state.showEmptyState = false; + + state.groups.forEach(group => { + group.metrics.forEach(metric => { + metric.queries.forEach(query => { + if (query.metric_id === metricId) { + state.metricsWithData.push(metricId); + // ensure dates/numbers are correctly formatted for charts + const normalizedResults = result.map(normalizeQueryResult); + Vue.set(query, 'result', Object.freeze(normalizedResults)); + } + }); + }); + }); + }, [types.SET_ENDPOINTS](state, endpoints) { state.metricsEndpoint = endpoints.metricsEndpoint; state.environmentsEndpoint = endpoints.environmentsEndpoint; state.deploymentsEndpoint = endpoints.deploymentsEndpoint; + state.dashboardEndpoint = endpoints.dashboardEndpoint; + }, + [types.SET_DASHBOARD_ENABLED](state, enabled) { + state.useDashboardEndpoint = enabled; }, [types.SET_GETTING_STARTED_EMPTY_STATE](state) { state.emptyState = 'gettingStarted'; }, + [types.SET_NO_DATA_EMPTY_STATE](state) { + state.showEmptyState = true; + state.emptyState = 'noData'; + }, }; diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index 5103122612a..c33529cd588 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -1,12 +1,17 @@ +import invalidUrl from '~/lib/utils/invalid_url'; + export default () => ({ hasMetrics: false, showPanels: true, metricsEndpoint: null, environmentsEndpoint: null, deploymentsEndpoint: null, + dashboardEndpoint: invalidUrl, + useDashboardEndpoint: false, emptyState: 'gettingStarted', showEmptyState: true, groups: [], deploymentData: [], environments: [], + metricsWithData: [], }); diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index 9216554ecbf..84e1f1c4c20 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -58,6 +58,14 @@ export const sortMetrics = metrics => .sortBy('weight') .value(); +export const normalizeQueryResult = timeSeries => ({ + ...timeSeries, + values: timeSeries.values.map(([timestamp, value]) => [ + new Date(timestamp * 1000).toISOString(), + Number(value), + ]), +}); + export const normalizeMetrics = metrics => { const groupedMetrics = groupQueriesByChartInfo(metrics); @@ -66,13 +74,7 @@ export const normalizeMetrics = metrics => { ...query, // custom metrics do not require a label, so we should ensure this attribute is defined label: query.label || metric.y_label, - result: query.result.map(result => ({ - ...result, - values: result.values.map(([timestamp, value]) => [ - new Date(timestamp * 1000).toISOString(), - Number(value), - ]), - })), + result: (query.result || []).map(normalizeQueryResult), })); return { diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js index f338dbbb0a6..98522c67696 100644 --- a/app/assets/javascripts/new_branch_form.js +++ b/app/assets/javascripts/new_branch_form.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, one-var, consistent-return, no-return-assign, prefer-arrow-callback, prefer-template, no-shadow, no-else-return */ +/* eslint-disable func-names, no-var, one-var, consistent-return, no-return-assign, prefer-arrow-callback, prefer-template, no-shadow, no-else-return, @gitlab/i18n/no-non-i18n-strings */ import $ from 'jquery'; import RefSelectDropdown from './ref_select_dropdown'; diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 09ecb695214..042ed196933 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -77,6 +77,11 @@ export default { required: false, default: '', }, + showSuggestPopover: { + type: Boolean, + required: false, + default: false, + }, }, data() { let updatedNoteBody = this.noteBody; @@ -247,6 +252,8 @@ export default { :can-suggest="canSuggest" :add-spacing-classes="false" :help-page-path="helpPagePath" + :show-suggest-popover="showSuggestPopover" + @handleSuggestDismissed="() => $emit('handleSuggestDismissed')" > <textarea id="note_note" @@ -303,7 +310,7 @@ export default { {{ __('Add comment now') }} </button> <button - class="btn btn-cancel note-edit-cancel js-close-discussion-note-form" + class="btn note-edit-cancel js-close-discussion-note-form" type="button" @click="cancelHandler()" > diff --git a/app/assets/javascripts/operation_settings/index.js b/app/assets/javascripts/operation_settings/index.js index 6946578e6d2..f075291ce98 100644 --- a/app/assets/javascripts/operation_settings/index.js +++ b/app/assets/javascripts/operation_settings/index.js @@ -3,14 +3,6 @@ import store from './store'; import ExternalDashboardForm from './components/external_dashboard.vue'; export default () => { - /** - * This check can be removed when we remove - * the :grafana_dashboard_link feature flag - */ - if (!gon.features.grafanaDashboardLink) { - return null; - } - const el = document.querySelector('.js-operation-settings'); return new Vue({ diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js index 377dce6c746..506e6075d16 100644 --- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js +++ b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js @@ -124,11 +124,14 @@ export const ContributorsGraph = (function() { }; ContributorsGraph.prototype.draw_x_axis = function() { - return this.svg - .append('g') - .attr('class', 'x axis') - .attr('transform', 'translate(0, ' + this.height + ')') - .call(this.x_axis); + return ( + this.svg + .append('g') + .attr('class', 'x axis') + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ + .attr('transform', 'translate(0, ' + this.height + ')') + .call(this.x_axis) + ); }; ContributorsGraph.prototype.draw_y_axis = function() { @@ -205,6 +208,7 @@ export const ContributorsMasterGraph = (function(superClass) { .attr('height', this.height + this.MARGIN.top + this.MARGIN.bottom) .attr('class', 'tint-box') .append('g') + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ .attr('transform', 'translate(' + this.MARGIN.left + ',' + this.MARGIN.top + ')'); return this.svg; }; @@ -354,6 +358,7 @@ export const ContributorsAuthorGraph = (function(superClass) { .attr('height', this.height + this.MARGIN.top + this.MARGIN.bottom) .attr('class', 'spark') .append('g') + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ .attr('transform', 'translate(' + this.MARGIN.left + ',' + this.MARGIN.top + ')'); return this.svg; }; diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index f3a71ee434c..b2e365e5cde 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -83,8 +83,6 @@ export default { v-if="shouldRenderContent" :status="status" :item-id="pipeline.id" - :item-iid="pipeline.iid" - :item-id-tooltip="__('Pipeline ID (IID)')" :time="pipeline.created_at" :user="pipeline.user" :actions="actions" diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue index 00c02e15562..c41ecab1294 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue @@ -2,7 +2,6 @@ import { GlLink, GlTooltipDirective } from '@gitlab/ui'; import _ from 'underscore'; import { __, sprintf } from '~/locale'; -import PipelineLink from '~/vue_shared/components/ci_pipeline_link.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import popover from '~/vue_shared/directives/popover'; @@ -20,7 +19,6 @@ export default { components: { UserAvatarLink, GlLink, - PipelineLink, }, directives: { GlTooltip: GlTooltipDirective, @@ -61,13 +59,10 @@ export default { }; </script> <template> - <div class="table-section section-10 d-none d-sm-none d-md-block pipeline-tags section-wrap"> - <pipeline-link - :href="pipeline.path" - :pipeline-id="pipeline.id" - :pipeline-iid="pipeline.iid" - class="js-pipeline-url-link" - /> + <div class="table-section section-10 d-none d-sm-none d-md-block pipeline-tags"> + <gl-link :href="pipeline.path" class="js-pipeline-url-link"> + <span class="pipeline-id">#{{ pipeline.id }}</span> + </gl-link> <div class="label-container"> <span v-if="pipeline.flags.latest" diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue index 1e4dfe76b26..f535b2ae9f2 100644 --- a/app/assets/javascripts/registry/components/table_registry.vue +++ b/app/assets/javascripts/registry/components/table_registry.vue @@ -64,7 +64,7 @@ export default { <th>{{ s__('ContainerRegistry|Tag') }}</th> <th>{{ s__('ContainerRegistry|Tag ID') }}</th> <th>{{ s__('ContainerRegistry|Size') }}</th> - <th>{{ s__('ContainerRegistry|Created') }}</th> + <th>{{ s__('ContainerRegistry|Last Updated') }}</th> <th></th> </tr> </thead> diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index e24a5e2c447..4519f82fc93 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -1,5 +1,6 @@ <script> import { GlBadge } from '@gitlab/ui'; +import { visitUrl } from '~/lib/utils/url_utility'; import { getIconName } from '../../utils/icon'; import getRefMixin from '../../mixins/get_ref'; @@ -63,6 +64,8 @@ export default { openRow() { if (this.isFolder) { this.$router.push(this.routerLinkTo); + } else { + visitUrl(this.url); } }, }, diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js index c64d16ef02a..ef147ec15cb 100644 --- a/app/assets/javascripts/repository/graphql.js +++ b/app/assets/javascripts/repository/graphql.js @@ -18,6 +18,7 @@ const defaultClient = createDefaultClient( cacheConfig: { fragmentMatcher, dataIdFromObject: obj => { + /* eslint-disable @gitlab/i18n/no-non-i18n-strings */ // eslint-disable-next-line no-underscore-dangle switch (obj.__typename) { // We need to create a dynamic ID for each entry @@ -33,6 +34,7 @@ const defaultClient = createDefaultClient( // eslint-disable-next-line no-underscore-dangle return obj.id || obj._id; } + /* eslint-enable @gitlab/i18n/no-non-i18n-strings */ }, }, }, diff --git a/app/assets/javascripts/repository/utils/title.js b/app/assets/javascripts/repository/utils/title.js index 4e194640e92..87d54c01200 100644 --- a/app/assets/javascripts/repository/utils/title.js +++ b/app/assets/javascripts/repository/utils/title.js @@ -5,5 +5,6 @@ export const setTitle = (pathMatch, ref, project) => { const path = pathMatch.replace(/^\//, ''); const isEmpty = path === ''; + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ document.title = `${isEmpty ? 'Files' : path} · ${ref} · ${project}`; }; diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index 6aca4067ba7..842fb5e5b4f 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -447,9 +447,11 @@ export class SearchAutocomplete { onClick(item, $el, e) { if (window.location.pathname.indexOf(item.url) !== -1) { if (!e.metaKey) e.preventDefault(); + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ if (item.category === 'Projects') { this.projectInputEl.val(item.id); } + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ if (item.category === 'Groups') { this.groupInputEl.val(item.id); } diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 7e6f02b10af..33cedf78331 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -427,6 +427,7 @@ function UsersSelect(currentUser, els, options = {}) { const isActive = $el.hasClass('is-active'); const previouslySelected = $dropdown .closest('.selectbox') + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ .find("input[name='" + $dropdown.data('fieldName') + "'][value!=0]"); // Enables support for limiting the number of users selected diff --git a/app/assets/javascripts/visual_review_toolbar/components/comment.js b/app/assets/javascripts/visual_review_toolbar/components/comment.js new file mode 100644 index 00000000000..2fec96d1435 --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/components/comment.js @@ -0,0 +1,132 @@ +import { BLACK, COMMENT_BOX, MUTED, LOGOUT } from './constants'; +import { clearNote, note, postError } from './note'; +import { buttonClearStyles, selectCommentBox, selectCommentButton, selectNote } from './utils'; + +const comment = ` + <div> + <textarea id="${COMMENT_BOX}" name="${COMMENT_BOX}" rows="3" placeholder="Enter your feedback or idea" class="gitlab-input" aria-required="true"></textarea> + ${note} + <p class="gitlab-metadata-note">Additional metadata will be included: browser, OS, current page, user agent, and viewport dimensions.</p> + </div> + <div class="gitlab-button-wrapper"> + <button class="gitlab-button gitlab-button-secondary" style="${buttonClearStyles}" type="button" id="${LOGOUT}"> Logout </button> + <button class="gitlab-button gitlab-button-success" style="${buttonClearStyles}" type="button" id="gitlab-comment-button"> Send feedback </button> + </div> +`; + +const resetCommentBox = () => { + const commentBox = selectCommentBox(); + const commentButton = selectCommentButton(); + + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ + commentButton.innerText = 'Send feedback'; + commentButton.classList.replace('gitlab-button-secondary', 'gitlab-button-success'); + commentButton.style.opacity = 1; + + commentBox.style.pointerEvents = 'auto'; + commentBox.style.color = BLACK; +}; + +const resetCommentButton = () => { + const commentBox = selectCommentBox(); + const currentNote = selectNote(); + + commentBox.value = ''; + currentNote.innerText = ''; +}; + +const resetComment = () => { + resetCommentBox(); + resetCommentButton(); +}; + +const confirmAndClear = mergeRequestId => { + const commentButton = selectCommentButton(); + const currentNote = selectNote(); + + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ + commentButton.innerText = 'Feedback sent'; + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ + currentNote.innerText = `Your comment was successfully posted to merge request #${mergeRequestId}`; + setTimeout(resetComment, 2000); +}; + +const setInProgressState = () => { + const commentButton = selectCommentButton(); + const commentBox = selectCommentBox(); + + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ + commentButton.innerText = 'Sending feedback'; + commentButton.classList.replace('gitlab-button-success', 'gitlab-button-secondary'); + commentButton.style.opacity = 0.5; + commentBox.style.color = MUTED; + commentBox.style.pointerEvents = 'none'; +}; + +const postComment = ({ + href, + platform, + browser, + userAgent, + innerWidth, + innerHeight, + projectId, + mergeRequestId, + mrUrl, + token, +}) => { + // Clear any old errors + clearNote(COMMENT_BOX); + + setInProgressState(); + + const commentText = selectCommentBox().value.trim(); + + if (!commentText) { + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ + postError('Your comment appears to be empty.', COMMENT_BOX); + resetCommentBox(); + return; + } + + const detailText = ` + \n +<details> + <summary>Metadata</summary> + Posted from ${href} | ${platform} | ${browser} | ${innerWidth} x ${innerHeight}. + <br /><br /> + <em>User agent: ${userAgent}</em> +</details> + `; + + const url = ` + ${mrUrl}/api/v4/projects/${projectId}/merge_requests/${mergeRequestId}/discussions`; + + const body = `${commentText} ${detailText}`; + + fetch(url, { + method: 'POST', + headers: { + 'PRIVATE-TOKEN': token, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ body }), + }) + .then(response => { + if (response.ok) { + confirmAndClear(mergeRequestId); + return; + } + + throw new Error(`${response.status}: ${response.statusText}`); + }) + .catch(err => { + postError( + `Your comment could not be sent. Please try again. Error: ${err.message}`, + COMMENT_BOX, + ); + resetCommentBox(); + }); +}; + +export { comment, postComment }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/constants.js b/app/assets/javascripts/visual_review_toolbar/components/constants.js new file mode 100644 index 00000000000..32ed1153515 --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/components/constants.js @@ -0,0 +1,37 @@ +// component selectors +const COLLAPSE_BUTTON = 'gitlab-collapse'; +const COMMENT_BOX = 'gitlab-comment'; +const COMMENT_BUTTON = 'gitlab-comment-button'; +const FORM = 'gitlab-form-wrapper'; +const LOGIN = 'gitlab-login'; +const LOGOUT = 'gitlab-logout-button'; +const NOTE = 'gitlab-validation-note'; +const REMEMBER_TOKEN = 'gitlab-remember_token'; +const REVIEW_CONTAINER = 'gitlab-review-container'; +const TOKEN_BOX = 'gitlab-token'; + +// colors — these are applied programmatically +// rest of styles belong in ./styles +const BLACK = 'rgba(46, 46, 46, 1)'; +const CLEAR = 'rgba(255, 255, 255, 0)'; +const MUTED = 'rgba(223, 223, 223, 0.5)'; +const RED = 'rgba(219, 59, 33, 1)'; +const WHITE = 'rgba(255, 255, 255, 1)'; + +export { + COLLAPSE_BUTTON, + COMMENT_BOX, + COMMENT_BUTTON, + FORM, + LOGIN, + LOGOUT, + NOTE, + REMEMBER_TOKEN, + REVIEW_CONTAINER, + TOKEN_BOX, + BLACK, + CLEAR, + MUTED, + RED, + WHITE, +}; diff --git a/app/assets/javascripts/visual_review_toolbar/components/index.js b/app/assets/javascripts/visual_review_toolbar/components/index.js new file mode 100644 index 00000000000..43581818152 --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/components/index.js @@ -0,0 +1,23 @@ +import { comment, postComment } from './comment'; +import { COLLAPSE_BUTTON, COMMENT_BUTTON, LOGIN, LOGOUT, REVIEW_CONTAINER } from './constants'; +import { authorizeUser, login } from './login'; +import { selectContainer } from './utils'; +import { form, logoutUser, toggleForm } from './wrapper'; +import { collapseButton } from './wrapper_icons'; + +export { + authorizeUser, + collapseButton, + comment, + form, + login, + logoutUser, + postComment, + selectContainer, + toggleForm, + COLLAPSE_BUTTON, + COMMENT_BUTTON, + LOGIN, + LOGOUT, + REVIEW_CONTAINER, +}; diff --git a/app/assets/javascripts/visual_review_toolbar/components/login.js b/app/assets/javascripts/visual_review_toolbar/components/login.js new file mode 100644 index 00000000000..ce713cdc520 --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/components/login.js @@ -0,0 +1,52 @@ +import { LOGIN, REMEMBER_TOKEN, TOKEN_BOX } from './constants'; +import { clearNote, note, postError } from './note'; +import { buttonClearStyles, selectRemember, selectToken } from './utils'; +import { addCommentForm } from './wrapper'; + +const login = ` + <div> + <label for="${TOKEN_BOX}" class="gitlab-label">Enter your <a class="gitlab-link" href="https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html">personal access token</a></label> + <input class="gitlab-input" type="password" id="${TOKEN_BOX}" name="${TOKEN_BOX}" aria-required="true" autocomplete="current-password"> + ${note} + </div> + <div class="gitlab-checkbox-wrapper"> + <input type="checkbox" id="${REMEMBER_TOKEN}" name="${REMEMBER_TOKEN}" value="remember"> + <label for="${REMEMBER_TOKEN}" class="gitlab-checkbox-label">Remember me</label> + </div> + <div class="gitlab-button-wrapper"> + <button class="gitlab-button-wide gitlab-button gitlab-button-success" style="${buttonClearStyles}" type="button" id="${LOGIN}"> Submit </button> + </div> +`; + +const storeToken = (token, state) => { + const { localStorage } = window; + const rememberMe = selectRemember().checked; + + // All the browsers we support have localStorage, so let's silently fail + // and go on with the rest of the functionality. + try { + if (rememberMe) { + localStorage.setItem('token', token); + } + } finally { + state.token = token; + } +}; + +const authorizeUser = state => { + // Clear any old errors + clearNote(TOKEN_BOX); + + const token = selectToken().value; + + if (!token) { + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ + postError('Please enter your token.', TOKEN_BOX); + return; + } + + storeToken(token, state); + addCommentForm(); +}; + +export { authorizeUser, login }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/note.js b/app/assets/javascripts/visual_review_toolbar/components/note.js new file mode 100644 index 00000000000..dfebf58fd95 --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/components/note.js @@ -0,0 +1,27 @@ +import { NOTE, RED } from './constants'; +import { selectById, selectNote } from './utils'; + +const note = ` + <p id=${NOTE} class='gitlab-message'></p> +`; + +const clearNote = inputId => { + const currentNote = selectNote(); + currentNote.innerText = ''; + currentNote.style.color = ''; + + if (inputId) { + const field = document.getElementById(inputId); + field.style.borderColor = ''; + } +}; + +const postError = (message, inputId) => { + const currentNote = selectNote(); + const field = selectById(inputId); + field.style.borderColor = RED; + currentNote.style.color = RED; + currentNote.innerText = message; +}; + +export { clearNote, note, postError }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/utils.js b/app/assets/javascripts/visual_review_toolbar/components/utils.js new file mode 100644 index 00000000000..7bc2e5a905b --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/components/utils.js @@ -0,0 +1,42 @@ +/* global document */ + +import { + COLLAPSE_BUTTON, + COMMENT_BOX, + COMMENT_BUTTON, + FORM, + NOTE, + REMEMBER_TOKEN, + REVIEW_CONTAINER, + TOKEN_BOX, +} from './constants'; + +// this style must be applied inline in a handful of components +/* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ +const buttonClearStyles = ` + -webkit-appearance: none; +`; + +// selector functions to abstract out a little +const selectById = id => document.getElementById(id); +const selectCollapseButton = () => document.getElementById(COLLAPSE_BUTTON); +const selectCommentBox = () => document.getElementById(COMMENT_BOX); +const selectCommentButton = () => document.getElementById(COMMENT_BUTTON); +const selectContainer = () => document.getElementById(REVIEW_CONTAINER); +const selectForm = () => document.getElementById(FORM); +const selectNote = () => document.getElementById(NOTE); +const selectRemember = () => document.getElementById(REMEMBER_TOKEN); +const selectToken = () => document.getElementById(TOKEN_BOX); + +export { + buttonClearStyles, + selectById, + selectCollapseButton, + selectContainer, + selectCommentBox, + selectCommentButton, + selectForm, + selectNote, + selectRemember, + selectToken, +}; diff --git a/app/assets/javascripts/visual_review_toolbar/components/wrapper.js b/app/assets/javascripts/visual_review_toolbar/components/wrapper.js new file mode 100644 index 00000000000..233b7ec496c --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/components/wrapper.js @@ -0,0 +1,82 @@ +import { comment } from './comment'; +import { CLEAR, FORM, WHITE } from './constants'; +import { login } from './login'; +import { selectCollapseButton, selectContainer, selectForm } from './utils'; +import { commentIcon, compressIcon } from './wrapper_icons'; + +const form = content => ` + <form id=${FORM}> + ${content} + </form> +`; + +const addCommentForm = () => { + const formWrapper = selectForm(); + formWrapper.innerHTML = comment; +}; + +const addLoginForm = () => { + const formWrapper = selectForm(); + formWrapper.innerHTML = login; +}; + +function logoutUser() { + const { localStorage } = window; + + // All the browsers we support have localStorage, so let's silently fail + // and go on with the rest of the functionality. + try { + localStorage.removeItem('token'); + } catch (err) { + return; + } + + addLoginForm(); +} + +function toggleForm() { + const container = selectContainer(); + const collapseButton = selectCollapseButton(); + const currentForm = selectForm(); + const OPEN = 'open'; + const CLOSED = 'closed'; + + /* + You may wonder why we spread the arrays before we reverse them. + In the immortal words of MDN, + Careful: reverse is destructive. It also changes the original array + */ + + const openButtonClasses = ['gitlab-collapse-closed', 'gitlab-collapse-open']; + const closedButtonClasses = [...openButtonClasses].reverse(); + const openContainerClasses = ['gitlab-closed-wrapper', 'gitlab-open-wrapper']; + const closedContainerClasses = [...openContainerClasses].reverse(); + + const stateVals = { + [OPEN]: { + buttonClasses: openButtonClasses, + containerClasses: openContainerClasses, + icon: compressIcon, + display: 'flex', + backgroundColor: WHITE, + }, + [CLOSED]: { + buttonClasses: closedButtonClasses, + containerClasses: closedContainerClasses, + icon: commentIcon, + display: 'none', + backgroundColor: CLEAR, + }, + }; + + const nextState = collapseButton.classList.contains('gitlab-collapse-open') ? CLOSED : OPEN; + const currentVals = stateVals[nextState]; + + container.classList.replace(...currentVals.containerClasses); + container.style.backgroundColor = currentVals.backgroundColor; + currentForm.style.display = currentVals.display; + collapseButton.classList.replace(...currentVals.buttonClasses); + collapseButton.innerHTML = currentVals.icon; +} + +export { addCommentForm, addLoginForm, form, logoutUser, toggleForm }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/wrapper_icons.js b/app/assets/javascripts/visual_review_toolbar/components/wrapper_icons.js new file mode 100644 index 00000000000..b686fd4f5c2 --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/components/wrapper_icons.js @@ -0,0 +1,15 @@ +import { buttonClearStyles } from './utils'; + +const commentIcon = ` + <svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>icn/comment</title><path d="M4 11.132l1.446-.964A1 1 0 0 1 6 10h5a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v6.132zM6.303 12l-2.748 1.832A1 1 0 0 1 2 13V5a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v4a3 3 0 0 1-3 3H6.303z" id="gitlab-comment-icon"/></svg> +`; + +const compressIcon = ` + <svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>icn/compress</title><path d="M5.27 12.182l-1.562 1.561a1 1 0 0 1-1.414 0h-.001a1 1 0 0 1 0-1.415l1.56-1.56L2.44 9.353a.5.5 0 0 1 .353-.854H7.09a.5.5 0 0 1 .5.5v4.294a.5.5 0 0 1-.853.353l-1.467-1.465zm6.911-6.914l1.464 1.464a.5.5 0 0 1-.353.854H8.999a.5.5 0 0 1-.5-.5V2.793a.5.5 0 0 1 .854-.354l1.414 1.415 1.56-1.561a1 1 0 1 1 1.415 1.414l-1.561 1.56z" id="gitlab-compress-icon"/></svg> +`; + +const collapseButton = ` + <button id='gitlab-collapse' style='${buttonClearStyles}' class='gitlab-button gitlab-button-secondary gitlab-collapse gitlab-collapse-open'>${compressIcon}</button> +`; + +export { commentIcon, compressIcon, collapseButton }; diff --git a/app/assets/javascripts/visual_review_toolbar/index.js b/app/assets/javascripts/visual_review_toolbar/index.js index 91d0382feac..941d77e25b4 100644 --- a/app/assets/javascripts/visual_review_toolbar/index.js +++ b/app/assets/javascripts/visual_review_toolbar/index.js @@ -1,2 +1,37 @@ import './styles/toolbar.css'; -import 'vendor/visual_review_toolbar'; + +import { form, selectContainer, REVIEW_CONTAINER } from './components'; +import { debounce, eventLookup, getInitialView, initializeState, updateWindowSize } from './store'; + +/* + + Welcome to the visual review toolbar files. A few useful notes: + + - These files build a static script that is served from our webpack + assets folder. (https://gitlab.com/assets/webpack/visual_review_toolbar.js) + + - To compile this file, run `yarn webpack-vrt`. + + - Vue is not used in these files because we do not want to ask users to + install another library at this time. It's all pure vanilla javascript. + +*/ + +window.addEventListener('load', () => { + initializeState(window, document); + + const { content, toggleButton } = getInitialView(window); + const container = document.createElement('div'); + + container.setAttribute('id', REVIEW_CONTAINER); + container.insertAdjacentHTML('beforeend', toggleButton); + container.insertAdjacentHTML('beforeend', form(content)); + + document.body.insertBefore(container, document.body.firstChild); + + selectContainer().addEventListener('click', event => { + eventLookup(event)(); + }); + + window.addEventListener('resize', debounce(updateWindowSize.bind(null, window), 200)); +}); diff --git a/app/assets/javascripts/visual_review_toolbar/store/events.js b/app/assets/javascripts/visual_review_toolbar/store/events.js new file mode 100644 index 00000000000..93996be8473 --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/store/events.js @@ -0,0 +1,36 @@ +import { + authorizeUser, + logoutUser, + postComment, + toggleForm, + COLLAPSE_BUTTON, + COMMENT_BUTTON, + LOGIN, + LOGOUT, +} from '../components'; + +import { state } from './state'; + +const noop = () => {}; + +const eventLookup = ({ target: { id } }) => { + switch (id) { + case COLLAPSE_BUTTON: + return toggleForm; + case COMMENT_BUTTON: + return postComment.bind(null, state); + case LOGIN: + return authorizeUser.bind(null, state); + case LOGOUT: + return logoutUser; + default: + return noop; + } +}; + +const updateWindowSize = wind => { + state.innerWidth = wind.innerWidth; + state.innerHeight = wind.innerHeight; +}; + +export { eventLookup, updateWindowSize }; diff --git a/app/assets/javascripts/visual_review_toolbar/store/index.js b/app/assets/javascripts/visual_review_toolbar/store/index.js new file mode 100644 index 00000000000..7143588c0bf --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/store/index.js @@ -0,0 +1,5 @@ +import { eventLookup, updateWindowSize } from './events'; +import { getInitialView, initializeState } from './state'; +import debounce from './utils'; + +export { debounce, eventLookup, getInitialView, initializeState, updateWindowSize }; diff --git a/app/assets/javascripts/visual_review_toolbar/store/state.js b/app/assets/javascripts/visual_review_toolbar/store/state.js new file mode 100644 index 00000000000..f5ede6e85b2 --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/store/state.js @@ -0,0 +1,77 @@ +import { comment, login, collapseButton } from '../components'; + +const state = { + browser: '', + href: '', + innerWidth: '', + innerHeight: '', + mergeRequestId: '', + mrUrl: '', + platform: '', + projectId: '', + userAgent: '', + token: '', +}; + +// adapted from https://developer.mozilla.org/en-US/docs/Web/API/Window/navigator#Example_2_Browser_detect_and_return_an_index +const getBrowserId = sUsrAg => { + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ + const aKeys = ['MSIE', 'Edge', 'Firefox', 'Safari', 'Chrome', 'Opera']; + let nIdx = aKeys.length - 1; + + for (nIdx; nIdx > -1 && sUsrAg.indexOf(aKeys[nIdx]) === -1; nIdx -= 1); + return aKeys[nIdx]; +}; + +const initializeState = (wind, doc) => { + const { + innerWidth, + innerHeight, + location: { href }, + navigator: { platform, userAgent }, + } = wind; + + const browser = getBrowserId(userAgent); + + const scriptEl = doc.getElementById('review-app-toolbar-script'); + const { projectId, mergeRequestId, mrUrl } = scriptEl.dataset; + + // This mutates our default state object above. It's weird but it makes the linter happy. + Object.assign(state, { + browser, + href, + innerWidth, + innerHeight, + mergeRequestId, + mrUrl, + platform, + projectId, + userAgent, + }); +}; + +function getInitialView({ localStorage }) { + const loginView = { + content: login, + toggleButton: collapseButton, + }; + + const commentView = { + content: comment, + toggleButton: collapseButton, + }; + + try { + const token = localStorage.getItem('token'); + + if (token) { + state.token = token; + return commentView; + } + return loginView; + } catch (err) { + return loginView; + } +} + +export { initializeState, getInitialView, state }; diff --git a/app/assets/javascripts/visual_review_toolbar/store/utils.js b/app/assets/javascripts/visual_review_toolbar/store/utils.js new file mode 100644 index 00000000000..5cf145351b3 --- /dev/null +++ b/app/assets/javascripts/visual_review_toolbar/store/utils.js @@ -0,0 +1,15 @@ +const debounce = (fn, time) => { + let current; + + const debounced = () => { + if (current) { + clearTimeout(current); + } + + current = setTimeout(fn, time); + }; + + return debounced; +}; + +export default debounce; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index c377c16fb13..f5fa68308bc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -5,7 +5,6 @@ import { sprintf, __ } from '~/locale'; import PipelineStage from '~/pipelines/components/stage.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import Icon from '~/vue_shared/components/icon.vue'; -import PipelineLink from '~/vue_shared/components/ci_pipeline_link.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import mrWidgetPipelineMixin from 'ee_else_ce/vue_merge_request_widget/mixins/mr_widget_pipeline'; @@ -17,7 +16,6 @@ export default { Icon, TooltipOnTruncate, GlLink, - PipelineLink, LinkedPipelinesMiniList: () => import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'), }, @@ -114,12 +112,9 @@ export default { <div class="media-body"> <div class="font-weight-bold js-pipeline-info-container"> {{ s__('Pipeline|Pipeline') }} - <pipeline-link - :href="pipeline.path" - :pipeline-id="pipeline.id" - :pipeline-iid="pipeline.iid" - class="pipeline-id pipeline-iid font-weight-normal" - /> + <gl-link :href="pipeline.path" class="pipeline-id font-weight-normal pipeline-number" + >#{{ pipeline.id }}</gl-link + > {{ pipeline.details.status.label }} <template v-if="hasCommitInfo"> {{ s__('Pipeline|for') }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue index 03a15ba81ed..ba9a4d2a187 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue @@ -1,4 +1,5 @@ <script> +import _ from 'underscore'; import Deployment from './deployment.vue'; import MrWidgetContainer from './mr_widget_container.vue'; import MrWidgetPipeline from './mr_widget_pipeline.vue'; @@ -17,6 +18,8 @@ export default { Deployment, MrWidgetContainer, MrWidgetPipeline, + MergeTrainInfo: () => + import('ee_component/vue_merge_request_widget/components/merge_train_info.vue'), }, props: { mr: { @@ -58,6 +61,9 @@ export default { showVisualReviewAppLink() { return Boolean(this.mr.visualReviewFF && this.mr.visualReviewAppAvailable); }, + showMergeTrainInfo() { + return _.isNumber(this.mr.mergeTrainIndex); + }, }, }; </script> @@ -83,6 +89,11 @@ export default { :visual-review-app-meta="visualReviewAppMeta" /> </div> + <merge-train-info + v-if="showMergeTrainInfo" + class="mr-widget-extension" + :merge-train-index="mr.mergeTrainIndex" + /> </template> </mr-widget-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue index f6f445c1cef..3df4a777aca 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue @@ -26,7 +26,7 @@ export default { ); }, showResolveButton() { - return this.mr.conflictResolutionPath && this.mr.canMerge; + return this.mr.conflictResolutionPath && this.mr.canPushToSourceBranch; }, showPopover() { return this.showResolveButton && this.mr.sourceBranchProtected; diff --git a/app/assets/javascripts/vue_shared/components/ci_pipeline_link.vue b/app/assets/javascripts/vue_shared/components/ci_pipeline_link.vue deleted file mode 100644 index eae4c06467c..00000000000 --- a/app/assets/javascripts/vue_shared/components/ci_pipeline_link.vue +++ /dev/null @@ -1,32 +0,0 @@ -<script> -import { GlLink, GlTooltipDirective } from '@gitlab/ui'; - -export default { - components: { - GlLink, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - href: { - type: String, - required: true, - }, - pipelineId: { - type: Number, - required: true, - }, - pipelineIid: { - type: Number, - required: true, - }, - }, -}; -</script> -<template> - <gl-link v-gl-tooltip :href="href" :title="__('Pipeline ID (IID)')"> - <span class="pipeline-id">#{{ pipelineId }}</span> - <span class="pipeline-iid">(#{{ pipelineIid }})</span> - </gl-link> -</template> diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index 0bac63b1062..3f45dc7853b 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -37,16 +37,6 @@ export default { type: Number, required: true, }, - itemIid: { - type: Number, - required: false, - default: null, - }, - itemIdTooltip: { - type: String, - required: false, - default: '', - }, time: { type: String, required: true, @@ -95,12 +85,7 @@ export default { <section class="header-main-content"> <ci-icon-badge :status="status" /> - <strong v-gl-tooltip :title="itemIdTooltip"> - {{ itemName }} #{{ itemId }} - <template v-if="itemIid" - >(#{{ itemIid }})</template - > - </strong> + <strong> {{ itemName }} #{{ itemId }} </strong> <template v-if="shouldRenderTriggeredLabel"> triggered @@ -111,8 +96,9 @@ export default { <timeago-tooltip :time="time" /> + by + <template v-if="user"> - by <gl-link v-gl-tooltip :href="user.path" diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 0f3b3568414..3bdc0bb8ebd 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -67,6 +67,11 @@ export default { required: false, default: '', }, + showSuggestPopover: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -194,8 +199,10 @@ export default { :preview-markdown="previewMarkdown" :line-content="lineContent" :can-suggest="canSuggest" + :show-suggest-popover="showSuggestPopover" @preview-markdown="showPreviewTab" @write-markdown="showWriteTab" + @handleSuggestDismissed="() => $emit('handleSuggestDismissed')" /> <div v-show="!previewMarkdown" class="md-write-holder"> <div class="zen-backdrop"> diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index a5a5b2ef415..56a16c9e4d6 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -1,6 +1,6 @@ <script> import $ from 'jquery'; -import { GlTooltipDirective } from '@gitlab/ui'; +import { GlPopover, GlButton, GlTooltipDirective } from '@gitlab/ui'; import ToolbarButton from './toolbar_button.vue'; import Icon from '../icon.vue'; @@ -8,6 +8,8 @@ export default { components: { ToolbarButton, Icon, + GlPopover, + GlButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -27,6 +29,11 @@ export default { required: false, default: true, }, + showSuggestPopover: { + type: Boolean, + required: false, + default: false, + }, }, computed: { mdTable() { @@ -70,6 +77,9 @@ export default { this.$emit('write-markdown'); }, + handleSuggestDismissed() { + this.$emit('handleSuggestDismissed'); + }, }, }; </script> @@ -93,66 +103,92 @@ export default { </button> </li> <li :class="{ active: !previewMarkdown }" class="md-header-toolbar"> - <toolbar-button tag="**" :button-title="__('Add bold text')" icon="bold" /> - <toolbar-button tag="*" :button-title="__('Add italic text')" icon="italic" /> - <toolbar-button - :prepend="true" - tag="> " - :button-title="__('Insert a quote')" - icon="quote" - /> - <toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" /> - <toolbar-button - tag="[{text}](url)" - tag-select="url" - :button-title="__('Add a link')" - icon="link" - /> - <toolbar-button - :prepend="true" - tag="* " - :button-title="__('Add a bullet list')" - icon="list-bulleted" - /> - <toolbar-button - :prepend="true" - tag="1. " - :button-title="__('Add a numbered list')" - icon="list-numbered" - /> - <toolbar-button - :prepend="true" - tag="* [ ] " - :button-title="__('Add a task list')" - icon="task-done" - /> - <toolbar-button - :tag="mdTable" - :prepend="true" - :button-title="__('Add a table')" - icon="table" - /> - <toolbar-button - v-if="canSuggest" - :tag="mdSuggestion" - :prepend="true" - :button-title="__('Insert suggestion')" - :cursor-offset="4" - :tag-content="lineContent" - icon="doc-code" - class="qa-suggestion-btn" - /> - <button - v-gl-tooltip - :aria-label="__('Go full screen')" - class="toolbar-btn toolbar-fullscreen-btn js-zen-enter" - data-container="body" - tabindex="-1" - :title="__('Go full screen')" - type="button" - > - <icon name="screen-full" /> - </button> + <div class="d-inline-block"> + <toolbar-button tag="**" :button-title="__('Add bold text')" icon="bold" /> + <toolbar-button tag="*" :button-title="__('Add italic text')" icon="italic" /> + <toolbar-button + :prepend="true" + tag="> " + :button-title="__('Insert a quote')" + icon="quote" + /> + </div> + <div class="d-inline-block ml-md-2 ml-0"> + <template v-if="canSuggest"> + <toolbar-button + ref="suggestButton" + :tag="mdSuggestion" + :prepend="true" + :button-title="__('Insert suggestion')" + :cursor-offset="4" + :tag-content="lineContent" + icon="doc-code" + class="qa-suggestion-btn" + @click="handleSuggestDismissed" + /> + <gl-popover + v-if="showSuggestPopover" + :target="() => $refs.suggestButton" + :css-classes="['diff-suggest-popover']" + placement="bottom" + :show="showSuggestPopover" + > + <strong>{{ __('New! Suggest changes directly') }}</strong> + <p class="mb-2"> + {{ __('Suggest code changes which are immediately applied. Try it out!') }} + </p> + <gl-button variant="primary" size="sm" @click="handleSuggestDismissed"> + {{ __('Got it') }} + </gl-button> + </gl-popover> + </template> + <toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" /> + <toolbar-button + tag="[{text}](url)" + tag-select="url" + :button-title="__('Add a link')" + icon="link" + /> + </div> + <div class="d-inline-block ml-md-2 ml-0"> + <toolbar-button + :prepend="true" + tag="* " + :button-title="__('Add a bullet list')" + icon="list-bulleted" + /> + <toolbar-button + :prepend="true" + tag="1. " + :button-title="__('Add a numbered list')" + icon="list-numbered" + /> + <toolbar-button + :prepend="true" + tag="* [ ] " + :button-title="__('Add a task list')" + icon="task-done" + /> + <toolbar-button + :tag="mdTable" + :prepend="true" + :button-title="__('Add a table')" + icon="table" + /> + </div> + <div class="d-inline-block ml-md-2 ml-0"> + <button + v-gl-tooltip + :aria-label="__('Go full screen')" + class="toolbar-btn toolbar-fullscreen-btn js-zen-enter" + data-container="body" + tabindex="-1" + :title="__('Go full screen')" + type="button" + > + <icon name="screen-full" /> + </button> + </div> </li> </ul> </div> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue index 4572caa907b..94f78c0c085 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue @@ -66,6 +66,7 @@ export default { class="toolbar-btn js-md" tabindex="-1" data-container="body" + @click="() => $emit('click')" > <icon :name="icon" /> </button> diff --git a/app/assets/javascripts/vue_shared/components/pagination/constants.js b/app/assets/javascripts/vue_shared/components/pagination/constants.js index c24b142ac7e..748ad178c70 100644 --- a/app/assets/javascripts/vue_shared/components/pagination/constants.js +++ b/app/assets/javascripts/vue_shared/components/pagination/constants.js @@ -7,3 +7,7 @@ export const PREV = s__('Pagination|Prev'); export const NEXT = s__('Pagination|Next'); export const FIRST = s__('Pagination|« First'); export const LAST = s__('Pagination|Last »'); +export const LABEL_FIRST_PAGE = s__('Pagination|Go to first page'); +export const LABEL_PREV_PAGE = s__('Pagination|Go to previous page'); +export const LABEL_NEXT_PAGE = s__('Pagination|Go to next page'); +export const LABEL_LAST_PAGE = s__('Pagination|Go to last page'); diff --git a/app/assets/javascripts/vue_shared/components/pagination_links.vue b/app/assets/javascripts/vue_shared/components/pagination_links.vue index 0b44f8578cb..06097913e91 100644 --- a/app/assets/javascripts/vue_shared/components/pagination_links.vue +++ b/app/assets/javascripts/vue_shared/components/pagination_links.vue @@ -1,6 +1,13 @@ <script> import { GlPagination } from '@gitlab/ui'; -import { s__ } from '../../locale'; +import { + PREV, + NEXT, + LABEL_FIRST_PAGE, + LABEL_PREV_PAGE, + LABEL_NEXT_PAGE, + LABEL_LAST_PAGE, +} from '~/vue_shared/components/pagination/constants'; export default { components: { @@ -16,23 +23,27 @@ export default { required: true, }, }, - firstText: s__('Pagination|« First'), - prevText: s__('Pagination|Prev'), - nextText: s__('Pagination|Next'), - lastText: s__('Pagination|Last »'), + prevText: PREV, + nextText: NEXT, + labelFirstPage: LABEL_FIRST_PAGE, + labelPrevPage: LABEL_PREV_PAGE, + labelNextPage: LABEL_NEXT_PAGE, + labelLastPage: LABEL_LAST_PAGE, }; </script> <template> <gl-pagination v-bind="$attrs" - :change="change" - :page="pageInfo.page" + :value="pageInfo.page" :per-page="pageInfo.perPage" :total-items="pageInfo.total" - :first-text="$options.firstText" :prev-text="$options.prevText" :next-text="$options.nextText" - :last-text="$options.lastText" + :label-first-page="$options.labelFirstPage" + :label-prev-page="$options.labelPrevPage" + :label-next-page="$options.labelNextPage" + :label-last-page="$options.labelLastPage" + @input="change" /> </template> |
