diff options
Diffstat (limited to 'app')
79 files changed, 1131 insertions, 213 deletions
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/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/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/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/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/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index d716fc211ca..0a652329dfe 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -70,7 +70,7 @@ export default { type: String, required: true, }, - deploymentEndpoint: { + deploymentsEndpoint: { type: String, required: false, default: null, @@ -148,7 +148,7 @@ export default { this.setEndpoints({ metricsEndpoint: this.metricsEndpoint, environmentsEndpoint: this.environmentsEndpoint, - deploymentsEndpoint: this.deploymentEndpoint, + deploymentsEndpoint: this.deploymentsEndpoint, dashboardEndpoint: this.dashboardEndpoint, }); @@ -280,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" @@ -296,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> diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 49ce722b838..f41e215cb5d 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -164,10 +164,10 @@ export const fetchPrometheusMetrics = ({ state, commit, dispatch }, params) => { }; 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/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/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/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_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> diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index db09118ba15..1bd5043ed10 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -416,6 +416,7 @@ img.emoji { .center { text-align: center; } .block { display: block; } .flex { display: flex; } +.vertical-align-top { vertical-align: top; } .vertical-align-middle { vertical-align: middle; } .vertical-align-sub { vertical-align: sub; } .flex-align-self-center { align-self: center; } diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index 2a601afff53..821e6691fe4 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -248,14 +248,24 @@ label { .gl-form-checkbox { align-items: baseline; + margin-right: 1rem; + margin-bottom: 0.25rem; + + .form-check-input { + margin-right: 0; + } + + .form-check-label { + padding-left: $gl-padding-8; + } &.form-check-inline .form-check-input { align-self: flex-start; - margin-right: $gl-padding-8; height: 1.5 * $gl-font-size; } - .help-text { - margin-bottom: 0; + .form-check-input:disabled, + .form-check-input:disabled ~ .form-check-label { + cursor: not-allowed; } } diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index bfd96a4bc05..0bf911eec0a 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -154,11 +154,9 @@ } .toolbar-fullscreen-btn { - margin-left: $gl-padding; margin-right: -5px; @include media-breakpoint-down(xs) { - margin-left: 0; margin-right: 0; } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 07fc655307e..b6a24247d40 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -823,7 +823,7 @@ $issues-analytics-popover-boarder-color: rgba(0, 0, 0, 0.15); /* Merge Requests */ -$mr-tabs-height: 51px; +$mr-tabs-height: 48px; $mr-version-controls-height: 56px; /* diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/pages/detail_page.scss index c386493231c..62fc7311d94 100644 --- a/app/assets/stylesheets/pages/detail_page.scss +++ b/app/assets/stylesheets/pages/detail_page.scss @@ -9,7 +9,6 @@ color: $gl-text-color; } - .issue_created_ago, .author-link { white-space: nowrap; } diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 4ebf1019456..d2d35d91e0b 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -14,7 +14,7 @@ position: -webkit-sticky; position: sticky; top: $mr-file-header-top; - z-index: 102; + z-index: 220; &::before { content: ''; @@ -1122,3 +1122,15 @@ table.code { outline: 0; } } + +.diff-suggest-popover { + &.popover { + width: 250px; + min-width: 250px; + z-index: 210; + } + + .popover-header { + display: none; + } +} diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index 2e5bdbd79c8..5615909c4ec 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -7,8 +7,8 @@ class GitlabSchema < GraphQL::Schema AUTHENTICATED_COMPLEXITY = 250 ADMIN_COMPLEXITY = 300 - DEFAULT_MAX_DEPTH = 10 - AUTHENTICATED_MAX_DEPTH = 15 + DEFAULT_MAX_DEPTH = 15 + AUTHENTICATED_MAX_DEPTH = 20 use BatchLoader::GraphQL use Gitlab::Graphql::Authorize diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index dd5133189dc..f2365499eee 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -4,6 +4,8 @@ module Types class IssueType < BaseObject graphql_name 'Issue' + implements(Types::Notes::NoteableType) + authorize :read_issue expose_permissions Types::PermissionTypes::Issue @@ -49,5 +51,7 @@ module Types field :created_at, Types::TimeType, null: false field :updated_at, Types::TimeType, null: false + + field :task_completion_status, Types::TaskCompletionStatus, null: false end end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index 85ac3102442..dac4c24cf10 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -4,6 +4,8 @@ module Types class MergeRequestType < BaseObject graphql_name 'MergeRequest' + implements(Types::Notes::NoteableType) + authorize :read_merge_request expose_permissions Types::PermissionTypes::MergeRequest @@ -53,5 +55,7 @@ module Types field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline field :pipelines, Types::Ci::PipelineType.connection_type, resolver: Resolvers::MergeRequestPipelinesResolver + + field :task_completion_status, Types::TaskCompletionStatus, null: false end end diff --git a/app/graphql/types/notes/diff_position_type.rb b/app/graphql/types/notes/diff_position_type.rb new file mode 100644 index 00000000000..104ccb79bbb --- /dev/null +++ b/app/graphql/types/notes/diff_position_type.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Types + module Notes + class DiffPositionType < BaseObject + graphql_name 'DiffPosition' + + field :head_sha, GraphQL::STRING_TYPE, null: false, + description: "The sha of the head at the time the comment was made" + field :base_sha, GraphQL::STRING_TYPE, null: true, + description: "The merge base of the branch the comment was made on" + field :start_sha, GraphQL::STRING_TYPE, null: false, + description: "The sha of the branch being compared against" + + field :file_path, GraphQL::STRING_TYPE, null: false, + description: "The path of the file that was changed" + field :old_path, GraphQL::STRING_TYPE, null: true, + description: "The path of the file on the start sha." + field :new_path, GraphQL::STRING_TYPE, null: true, + description: "The path of the file on the head sha." + field :position_type, Types::Notes::PositionTypeEnum, null: false + + # Fields for text positions + field :old_line, GraphQL::INT_TYPE, null: true, + description: "The line on start sha that was changed", + resolve: -> (position, _args, _ctx) { position.old_line if position.on_text? } + field :new_line, GraphQL::INT_TYPE, null: true, + description: "The line on head sha that was changed", + resolve: -> (position, _args, _ctx) { position.new_line if position.on_text? } + + # Fields for image positions + field :x, GraphQL::INT_TYPE, null: true, + description: "The X postion on which the comment was made", + resolve: -> (position, _args, _ctx) { position.x if position.on_image? } + field :y, GraphQL::INT_TYPE, null: true, + description: "The Y position on which the comment was made", + resolve: -> (position, _args, _ctx) { position.y if position.on_image? } + field :width, GraphQL::INT_TYPE, null: true, + description: "The total width of the image", + resolve: -> (position, _args, _ctx) { position.width if position.on_image? } + field :height, GraphQL::INT_TYPE, null: true, + description: "The total height of the image", + resolve: -> (position, _args, _ctx) { position.height if position.on_image? } + end + end +end diff --git a/app/graphql/types/notes/discussion_type.rb b/app/graphql/types/notes/discussion_type.rb new file mode 100644 index 00000000000..c4691942f2d --- /dev/null +++ b/app/graphql/types/notes/discussion_type.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Notes + class DiscussionType < BaseObject + graphql_name 'Discussion' + + authorize :read_note + + field :id, GraphQL::ID_TYPE, null: false + field :created_at, Types::TimeType, null: false + field :notes, Types::Notes::NoteType.connection_type, null: false, description: "All notes in the discussion" + end + end +end diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb new file mode 100644 index 00000000000..85c55d16ac2 --- /dev/null +++ b/app/graphql/types/notes/note_type.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Types + module Notes + class NoteType < BaseObject + graphql_name 'Note' + + authorize :read_note + + expose_permissions Types::PermissionTypes::Note + + field :id, GraphQL::ID_TYPE, null: false + + field :project, Types::ProjectType, + null: true, + description: "The project this note is associated to", + resolve: -> (note, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, note.project_id).find } + + field :author, Types::UserType, + null: false, + description: "The user who wrote this note", + resolve: -> (note, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, note.author_id).find } + + field :resolved_by, Types::UserType, + null: true, + description: "The user that resolved the discussion", + resolve: -> (note, _args, _context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, note.resolved_by_id).find } + + field :system, GraphQL::BOOLEAN_TYPE, + null: false, + description: "Whether or not this note was created by the system or by a user" + + field :body, GraphQL::STRING_TYPE, + null: false, + method: :note, + description: "The content note itself" + + field :created_at, Types::TimeType, null: false + field :updated_at, Types::TimeType, null: false + field :discussion, Types::Notes::DiscussionType, null: true, description: "The discussion this note is a part of" + field :resolvable, GraphQL::BOOLEAN_TYPE, null: false, method: :resolvable? + field :resolved_at, Types::TimeType, null: true, description: "The time the discussion was resolved" + field :position, Types::Notes::DiffPositionType, null: true, description: "The position of this note on a diff" + end + end +end diff --git a/app/graphql/types/notes/noteable_type.rb b/app/graphql/types/notes/noteable_type.rb new file mode 100644 index 00000000000..9f126d67b0d --- /dev/null +++ b/app/graphql/types/notes/noteable_type.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Types + module Notes + module NoteableType + include Types::BaseInterface + + field :notes, Types::Notes::NoteType.connection_type, null: false, description: "All notes on this noteable" + field :discussions, Types::Notes::DiscussionType.connection_type, null: false, description: "All discussions on this noteable" + + definition_methods do + def resolve_type(object, context) + case object + when Issue + Types::IssueType + when MergeRequest + Types::MergeRequestType + else + raise "Unknown GraphQL type for #{object}" + end + end + end + end + end +end diff --git a/app/graphql/types/notes/position_type_enum.rb b/app/graphql/types/notes/position_type_enum.rb new file mode 100644 index 00000000000..abdb2cfc804 --- /dev/null +++ b/app/graphql/types/notes/position_type_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Notes + class PositionTypeEnum < BaseEnum + graphql_name 'DiffPositionType' + description 'Type of file the position refers to' + + value 'text' + value 'image' + end + end +end diff --git a/app/graphql/types/permission_types/note.rb b/app/graphql/types/permission_types/note.rb new file mode 100644 index 00000000000..a585d3daaa8 --- /dev/null +++ b/app/graphql/types/permission_types/note.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module PermissionTypes + class Note < BasePermissionType + graphql_name 'NotePermissions' + + abilities :read_note, :create_note, :admin_note, :resolve_note, :award_emoji + end + end +end diff --git a/app/graphql/types/project_statistics_type.rb b/app/graphql/types/project_statistics_type.rb index 62537361918..4000c6db280 100644 --- a/app/graphql/types/project_statistics_type.rb +++ b/app/graphql/types/project_statistics_type.rb @@ -4,6 +4,8 @@ module Types class ProjectStatisticsType < BaseObject graphql_name 'ProjectStatistics' + authorize :read_statistics + field :commit_count, GraphQL::INT_TYPE, null: false field :storage_size, GraphQL::INT_TYPE, null: false diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 2236ffa394d..81914b70c7f 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -70,7 +70,7 @@ module Types field :group, Types::GroupType, null: true field :statistics, Types::ProjectStatisticsType, - null: false, + null: true, resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchProjectStatisticsLoader.new(obj.id).find } field :repository, Types::RepositoryType, null: false diff --git a/app/graphql/types/task_completion_status.rb b/app/graphql/types/task_completion_status.rb new file mode 100644 index 00000000000..c289802509d --- /dev/null +++ b/app/graphql/types/task_completion_status.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + class TaskCompletionStatus < BaseObject + graphql_name 'TaskCompletionStatus' + description 'Completion status of tasks' + + field :count, GraphQL::INT_TYPE, null: false + field :completed_count, GraphQL::INT_TYPE, null: false + end +end diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index 8d8c62f1291..64c5fae7d96 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -91,7 +91,7 @@ module DropdownsHelper def dropdown_filter(placeholder, search_id: nil) content_tag :div, class: "dropdown-input" do - filter_output = search_field_tag search_id, nil, class: "dropdown-input-field", placeholder: placeholder, autocomplete: 'off' + filter_output = search_field_tag search_id, nil, class: "dropdown-input-field qa-dropdown-input-field", placeholder: placeholder, autocomplete: 'off' filter_output << icon('search', class: "dropdown-input-search") filter_output << icon('times', class: "dropdown-input-clear js-dropdown-input-clear", role: "button") diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index 855b243cc8a..0f118c235d8 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -27,7 +27,7 @@ module EnvironmentsHelper "empty-unable-to-connect-svg-path" => image_path('illustrations/monitoring/unable_to_connect.svg'), "metrics-endpoint" => additional_metrics_project_environment_path(project, environment, format: :json), "dashboard-endpoint" => metrics_dashboard_project_environment_path(project, environment, format: :json), - "deployment-endpoint" => project_environment_deployments_path(project, environment, format: :json), + "deployments-endpoint" => project_environment_deployments_path(project, environment, format: :json), "environments-endpoint": project_environments_path(project, format: :json), "project-path" => project_path(project), "tags-path" => project_tags_path(project), diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index dce4168ad7b..bf894360a2e 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -263,6 +263,11 @@ module MarkupHelper end def asciidoc_unsafe(text, context = {}) + context.merge!( + commit: @commit, + ref: @ref, + requested_path: @path + ) Gitlab::Asciidoc.render(text, context) end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 4594f5a31b9..dfa34ad7020 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -172,11 +172,17 @@ module SearchHelper if @project.present? opts[:data]['project-id'] = @project.id opts[:data]['base-endpoint'] = project_path(@project) + opts[:data]['labels-endpoint'] = project_labels_path(@project) + opts[:data]['milestones-endpoint'] = project_milestones_path(@project) elsif @group.present? opts[:data]['group-id'] = @group.id opts[:data]['base-endpoint'] = group_canonical_path(@group) + opts[:data]['labels-endpoint'] = group_labels_path(@group) + opts[:data]['milestones-endpoint'] = group_milestones_path(@group) else opts[:data]['base-endpoint'] = root_dashboard_path + opts[:data]['labels-endpoint'] = dashboard_labels_path + opts[:data]['milestones-endpoint'] = dashboard_milestones_path end opts diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb index 5d658d35107..d5e459311f7 100644 --- a/app/helpers/user_callouts_helper.rb +++ b/app/helpers/user_callouts_helper.rb @@ -3,6 +3,7 @@ module UserCalloutsHelper GKE_CLUSTER_INTEGRATION = 'gke_cluster_integration'.freeze GCP_SIGNUP_OFFER = 'gcp_signup_offer'.freeze + SUGGEST_POPOVER_DISMISSED = 'suggest_popover_dismissed'.freeze def show_gke_cluster_integration_callout?(project) can?(current_user, :create_cluster, project) && @@ -20,6 +21,10 @@ module UserCalloutsHelper def render_dashboard_gold_trial(user) end + def show_suggest_popover? + !user_dismissed?(SUGGEST_POPOVER_DISMISSED) + end + private def user_dismissed?(feature_name) diff --git a/app/models/concerns/diff_positionable_note.rb b/app/models/concerns/diff_positionable_note.rb index b61bf29e6ad..2d09eff0111 100644 --- a/app/models/concerns/diff_positionable_note.rb +++ b/app/models/concerns/diff_positionable_note.rb @@ -3,6 +3,7 @@ module DiffPositionableNote extend ActiveSupport::Concern included do + delegate :on_text?, :on_image?, to: :position, allow_nil: true before_validation :set_original_position, on: :create before_validation :update_position, on: :create, if: :on_text? @@ -28,14 +29,6 @@ module DiffPositionableNote end end - def on_text? - position&.position_type == "text" - end - - def on_image? - position&.position_type == "image" - end - def supported? for_commit? || self.noteable.has_complete_diff_refs? end diff --git a/app/models/discussion.rb b/app/models/discussion.rb index 32529ebf71d..ae13cdfd85f 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -4,6 +4,7 @@ # # A discussion of this type can be resolvable. class Discussion + include GlobalID::Identification include ResolvableDiscussion attr_reader :notes, :context_noteable @@ -11,14 +12,19 @@ class Discussion delegate :created_at, :project, :author, - :noteable, :commit_id, :for_commit?, :for_merge_request?, + :to_ability_name, + :editable?, to: :first_note + def declarative_policy_delegate + first_note + end + def project_id project&.id end diff --git a/app/models/group.rb b/app/models/group.rb index cdb4e6e87f6..dbec211935d 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -423,7 +423,7 @@ class Group < Namespace def update_two_factor_requirement return unless saved_change_to_require_two_factor_authentication? || saved_change_to_two_factor_grace_period? - users.find_each(&:update_two_factor_requirement) + members_with_descendants.find_each(&:update_two_factor_requirement) end def path_changed_hook diff --git a/app/models/note.rb b/app/models/note.rb index 081d6f91230..15271c68a9e 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -342,7 +342,7 @@ class Note < ApplicationRecord end def to_ability_name - for_snippet? ? noteable.class.name.underscore : noteable_type.underscore + for_snippet? ? noteable.class.name.underscore : noteable_type.demodulize.underscore end def can_be_discussion_note? diff --git a/app/models/user_callout_enums.rb b/app/models/user_callout_enums.rb index b9373ae6166..7b68e5076c7 100644 --- a/app/models/user_callout_enums.rb +++ b/app/models/user_callout_enums.rb @@ -6,11 +6,15 @@ module UserCalloutEnums # # This method is separate from the `UserCallout` model so that it can be # extended by EE. + # + # If you are going to add new items to this hash, check that you're not going + # to conflict with EE-only values: https://gitlab.com/gitlab-org/gitlab-ee/blob/master/ee/app/models/ee/user_callout_enums.rb def self.feature_names { gke_cluster_integration: 1, gcp_signup_offer: 2, - cluster_security_warning: 3 + cluster_security_warning: 3, + suggest_popover_dismissed: 9 } end end diff --git a/app/policies/project_statistics_policy.rb b/app/policies/project_statistics_policy.rb new file mode 100644 index 00000000000..c0592f1ea13 --- /dev/null +++ b/app/policies/project_statistics_policy.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ProjectStatisticsPolicy < BasePolicy + delegate { @subject.project } +end diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index ec2698ecbe3..9ef93b2387f 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/pipeline_entity.rb @@ -4,7 +4,6 @@ class PipelineEntity < Grape::Entity include RequestAwareEntity expose :id - expose :iid expose :user, using: UserEntity expose :active?, as: :active diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index c357207054b..7535aee83a3 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -78,3 +78,4 @@ = render 'layouts/google_analytics' if extra_config.has_key?('google_analytics_id') = render 'layouts/piwik' if extra_config.has_key?('piwik_url') && extra_config.has_key?('piwik_site_id') = render_if_exists 'layouts/snowplow' + = render_if_exists 'layouts/pendo' if Feature.enabled?(:pendo_tracking) && !Rails.env.test? diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 9b6551552c7..49ff976f8e8 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -9,7 +9,7 @@ = @project.name %ul.sidebar-top-level-items = nav_link(path: sidebar_projects_paths, html_options: { class: 'home' }) do - = link_to project_path(@project), class: 'shortcuts-project' do + = link_to project_path(@project), class: 'shortcuts-project qa-link-project' do .nav-icon-container = sprite_icon('home') %span.nav-item-name diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index bdf7b933ab8..f4560404c03 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -53,10 +53,9 @@ %span.badge.badge-info= _('manual') - if pipeline_link - %td.pipeline-link - = link_to pipeline_path(pipeline), class: 'has-tooltip', title: _('Pipeline ID (IID)') do + %td + = link_to pipeline_path(pipeline) do %span.pipeline-id ##{pipeline.id} - %span.pipeline-iid (##{pipeline.iid}) %span by - if pipeline.user = user_avatar(user: pipeline.user, size: 20) diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 77ea2c04b28..a766dd51463 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -81,7 +81,7 @@ = link_to project_pipeline_path(@project, last_pipeline.id), class: "ci-status-icon-#{last_pipeline.status}" do = ci_icon_for_status(last_pipeline.status) #{ _('Pipeline') } - = link_to "##{last_pipeline.id} (##{last_pipeline.iid})", project_pipeline_path(@project, last_pipeline.id), class: "has-tooltip", title: _('Pipeline ID (IID)') + = link_to "##{last_pipeline.id}", project_pipeline_path(@project, last_pipeline.id) = ci_label_for_status(last_pipeline.status) - if last_pipeline.stages_count.nonzero? #{ n_(s_('Pipeline|with stage'), s_('Pipeline|with stages'), last_pipeline.stages_count) } diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index a201fafb949..f593f4e049e 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -80,7 +80,9 @@ current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json, project_path: project_path(@merge_request.project), changes_empty_state_illustration: image_path('illustrations/merge_request_changes_empty.svg'), - is_fluid_layout: fluid_layout.to_s } } + is_fluid_layout: fluid_layout.to_s, + dismiss_endpoint: user_callouts_path, + show_suggest_popover: show_suggest_popover?.to_s } } .mr-loading-status = spinner diff --git a/app/views/shared/_sidebar_toggle_button.html.haml b/app/views/shared/_sidebar_toggle_button.html.haml index d90a6d43761..d499bc0a253 100644 --- a/app/views/shared/_sidebar_toggle_button.html.haml +++ b/app/views/shared/_sidebar_toggle_button.html.haml @@ -1,4 +1,4 @@ -%a.toggle-sidebar-button.js-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" } +%a.toggle-sidebar-button.js-toggle-sidebar.qa-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" } = sprite_icon('angle-double-left', css_class: 'icon-angle-double-left') = sprite_icon('angle-double-right', css_class: 'icon-angle-double-right') %span.collapse-text= _("Collapse sidebar") diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 3a5adb34ad1..e87e560266f 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -102,7 +102,7 @@ = _('Labels') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable - = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right' + = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link qa-edit-link-labels float-right' .value.issuable-show-labels.dont-hide.hide-collapsed.qa-labels-block{ class: ("has-labels" if selected_labels.any?) } - if selected_labels.any? - selected_labels.each do |label_hash| @@ -118,7 +118,7 @@ %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) } = multi_label_name(selected_labels, "Labels") = icon('chevron-down', 'aria-hidden': 'true') - .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height + .dropdown-menu.dropdown-select.dropdown-menu-paging.qa-dropdown-menu-labels.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height = render partial: "shared/issuable/label_page_default" - if issuable_sidebar.dig(:current_user, :can_admin_label) = render partial: "shared/issuable/label_page_create" |