diff options
Diffstat (limited to 'app/assets/javascripts')
557 files changed, 11462 insertions, 6385 deletions
diff --git a/app/assets/javascripts/admin/users/components/actions/ban.vue b/app/assets/javascripts/admin/users/components/actions/ban.vue index 4e9cefbfdd7..e5ab0f9123f 100644 --- a/app/assets/javascripts/admin/users/components/actions/ban.vue +++ b/app/assets/javascripts/admin/users/components/actions/ban.vue @@ -6,10 +6,11 @@ import { I18N_USER_ACTIONS } from '../../constants'; // TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 const messageHtml = ` - <p>${s__('AdminUsers|When banned, users:')}</p> + <p>${s__('AdminUsers|When banned:')}</p> <ul> - <li>${s__("AdminUsers|Can't log in.")}</li> - <li>${s__("AdminUsers|Can't access Git repositories.")}</li> + <li>${s__("AdminUsers|The user can't log in.")}</li> + <li>${s__("AdminUsers|The user can't access git repositories.")}</li> + <li>${s__('AdminUsers|Issues authored by this user are hidden from other users.')}</li> </ul> <p>${s__('AdminUsers|You can unban their account in the future. Their data remains intact.')}</p> <p>${sprintf( diff --git a/app/assets/javascripts/admin/users/components/actions/delete.vue b/app/assets/javascripts/admin/users/components/actions/delete.vue index 6f4f272154a..a0f4a4bf382 100644 --- a/app/assets/javascripts/admin/users/components/actions/delete.vue +++ b/app/assets/javascripts/admin/users/components/actions/delete.vue @@ -28,6 +28,7 @@ export default { modal-type="delete" :username="username" :paths="paths" + :delete-path="paths.delete" :oncall-schedules="oncallSchedules" > <slot></slot> diff --git a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue index 82b09c04ab2..02fd3efafa1 100644 --- a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue +++ b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue @@ -28,6 +28,7 @@ export default { modal-type="delete-with-contributions" :username="username" :paths="paths" + :delete-path="paths.deleteWithContributions" :oncall-schedules="oncallSchedules" > <slot></slot> diff --git a/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue b/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue index b3b68442e80..a1589c9d46d 100644 --- a/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue +++ b/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue @@ -14,6 +14,10 @@ export default { type: Object, required: true, }, + deletePath: { + type: String, + required: true, + }, modalType: { type: String, required: true, @@ -27,7 +31,7 @@ export default { modalAttributes() { return { 'data-block-user-url': this.paths.block, - 'data-delete-user-url': this.paths.delete, + 'data-delete-user-url': this.deletePath, 'data-gl-modal-action': this.modalType, 'data-username': this.username, 'data-oncall-schedules': JSON.stringify(this.oncallSchedules), diff --git a/app/assets/javascripts/analytics/devops_report/components/devops_score.vue b/app/assets/javascripts/analytics/devops_report/components/devops_score.vue index 1a3289ffb75..238081cc3c0 100644 --- a/app/assets/javascripts/analytics/devops_report/components/devops_score.vue +++ b/app/assets/javascripts/analytics/devops_report/components/devops_score.vue @@ -1,7 +1,9 @@ <script> import { GlBadge, GlTable, GlLink, GlEmptyState } from '@gitlab/ui'; import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { sprintf, s__ } from '~/locale'; +import DevopsScoreCallout from './devops_score_callout.vue'; const defaultHeaderAttrs = { thClass: 'gl-bg-white!', @@ -15,14 +17,12 @@ export default { GlSingleStat, GlLink, GlEmptyState, + DevopsScoreCallout, }, inject: { devopsScoreMetrics: { default: null, }, - devopsReportDocsPath: { - default: '', - }, noDataImagePath: { default: '', }, @@ -40,6 +40,7 @@ export default { return this.devopsScoreMetrics.averageScore === undefined; }, }, + devopsReportDocsPath: helpPagePath('user/admin_area/analytics/dev_ops_report'), tableHeaderFields: [ { key: 'title', @@ -65,46 +66,49 @@ export default { }; </script> <template> - <gl-empty-state - v-if="isEmpty" - :title="__('Data is still calculating...')" - :svg-path="noDataImagePath" - > - <template #description> - <p class="gl-mb-0">{{ __('It may be several days before you see feature usage data.') }}</p> - <gl-link :href="devopsReportDocsPath">{{ - __('See example DevOps Score page in our documentation.') - }}</gl-link> - </template> - </gl-empty-state> - <div v-else data-testid="devops-score-app"> - <div class="gl-text-gray-400 gl-my-4" data-testid="devops-score-note-text"> - {{ titleHelperText }} - </div> - <gl-single-stat - unit="%" - size="sm" - :title="s__('DevopsReport|Your score')" - :should-animate="true" - :value="devopsScoreMetrics.averageScore.value" - :meta-icon="devopsScoreMetrics.averageScore.scoreLevel.icon" - :meta-text="devopsScoreMetrics.averageScore.scoreLevel.label" - :variant="devopsScoreMetrics.averageScore.scoreLevel.variant" - /> - <gl-table - :fields="$options.tableHeaderFields" - :items="devopsScoreMetrics.cards" - thead-class="gl-border-t-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-100" - stacked="sm" + <div data-testid="devops-score-container"> + <devops-score-callout /> + <gl-empty-state + v-if="isEmpty" + :title="__('Data is still calculating...')" + :svg-path="noDataImagePath" > - <template #cell(usage)="{ item }"> - <div data-testid="usageCol"> - <span>{{ item.usage }}</span> - <gl-badge :variant="item.scoreLevel.variant" size="sm" class="gl-ml-1">{{ - item.scoreLevel.label - }}</gl-badge> - </div> + <template #description> + <p class="gl-mb-0">{{ __('It may be several days before you see feature usage data.') }}</p> + <gl-link :href="$options.devopsReportDocsPath">{{ + __('See example DevOps Score page in our documentation.') + }}</gl-link> </template> - </gl-table> + </gl-empty-state> + <div v-else data-testid="devops-score-app"> + <div class="gl-text-gray-400 gl-my-4" data-testid="devops-score-note-text"> + {{ titleHelperText }} + </div> + <gl-single-stat + unit="%" + size="sm" + :title="s__('DevopsReport|Your score')" + :should-animate="true" + :value="devopsScoreMetrics.averageScore.value" + :meta-icon="devopsScoreMetrics.averageScore.scoreLevel.icon" + :meta-text="devopsScoreMetrics.averageScore.scoreLevel.label" + :variant="devopsScoreMetrics.averageScore.scoreLevel.variant" + /> + <gl-table + :fields="$options.tableHeaderFields" + :items="devopsScoreMetrics.cards" + thead-class="gl-border-t-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-100" + stacked="sm" + > + <template #cell(usage)="{ item }"> + <div data-testid="usageCol"> + <span>{{ item.usage }}</span> + <gl-badge :variant="item.scoreLevel.variant" size="sm" class="gl-ml-1">{{ + item.scoreLevel.label + }}</gl-badge> + </div> + </template> + </gl-table> + </div> </div> </template> diff --git a/app/assets/javascripts/analytics/devops_report/components/devops_score_callout.vue b/app/assets/javascripts/analytics/devops_report/components/devops_score_callout.vue new file mode 100644 index 00000000000..e594b4e360a --- /dev/null +++ b/app/assets/javascripts/analytics/devops_report/components/devops_score_callout.vue @@ -0,0 +1,55 @@ +<script> +import { GlBanner } from '@gitlab/ui'; +import { parseBoolean, getCookie, setCookie } from '~/lib/utils/common_utils'; +import { + INTRO_COOKIE_KEY, + INTRO_BANNER_TITLE, + INTRO_BANNER_BODY, + INTRO_BANNER_ACTION_TEXT, +} from '../constants'; + +export default { + name: 'DevopsScoreCallout', + components: { + GlBanner, + }, + inject: { + devopsReportDocsPath: { + default: '', + }, + devopsScoreIntroImagePath: { + default: '', + }, + }, + data() { + return { + bannerDismissed: parseBoolean(getCookie(INTRO_COOKIE_KEY)), + }; + }, + i18n: { + title: INTRO_BANNER_TITLE, + body: INTRO_BANNER_BODY, + action: INTRO_BANNER_ACTION_TEXT, + }, + methods: { + dismissBanner() { + setCookie(INTRO_COOKIE_KEY, 'true'); + this.bannerDismissed = true; + }, + }, +}; +</script> +<template> + <gl-banner + v-if="!bannerDismissed" + class="gl-mt-3" + variant="introduction" + :title="$options.i18n.title" + :button-text="$options.i18n.action" + :button-link="devopsReportDocsPath" + :svg-path="devopsScoreIntroImagePath" + @close="dismissBanner" + > + <p>{{ $options.i18n.body }}</p> + </gl-banner> +</template> diff --git a/app/assets/javascripts/analytics/devops_report/components/service_ping_disabled.vue b/app/assets/javascripts/analytics/devops_report/components/service_ping_disabled.vue index 7c14cf3767f..400326e41e1 100644 --- a/app/assets/javascripts/analytics/devops_report/components/service_ping_disabled.vue +++ b/app/assets/javascripts/analytics/devops_report/components/service_ping_disabled.vue @@ -1,5 +1,6 @@ <script> import { GlEmptyState, GlSprintf, GlLink, GlButton } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; export default { components: { @@ -15,13 +16,11 @@ export default { svgPath: { default: '', }, - docsLink: { - default: '', - }, primaryButtonPath: { default: '', }, }, + docsLink: helpPagePath('development/service_ping/index.md'), }; </script> <template> @@ -36,7 +35,7 @@ export default { " > <template #docLink="{ content }"> - <gl-link :href="docsLink" target="_blank" data-testid="docs-link">{{ content }}</gl-link> + <gl-link :href="$options.docsLink" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> <template v-else> @@ -44,12 +43,7 @@ export default { {{ s__('ServicePing|Turn on service ping to review instance-level analytics.') }} </p> - <gl-button - category="primary" - variant="success" - :href="primaryButtonPath" - data-testid="power-on-button" - > + <gl-button category="primary" variant="success" :href="primaryButtonPath"> {{ s__('ServicePing|Turn on service ping') }} </gl-button> </template> diff --git a/app/assets/javascripts/analytics/devops_report/constants.js b/app/assets/javascripts/analytics/devops_report/constants.js new file mode 100644 index 00000000000..b395d7eb464 --- /dev/null +++ b/app/assets/javascripts/analytics/devops_report/constants.js @@ -0,0 +1,11 @@ +import { __ } from '~/locale'; + +export const INTRO_COOKIE_KEY = 'dev_ops_report_intro_callout_dismissed'; + +export const INTRO_BANNER_TITLE = __('Introducing Your DevOps Report'); + +export const INTRO_BANNER_BODY = __( + 'Your DevOps Report gives an overview of how you are using GitLab from a feature perspective. Use it to view how you compare with other organizations.', +); + +export const INTRO_BANNER_ACTION_TEXT = __('Read more'); diff --git a/app/assets/javascripts/analytics/devops_report/devops_score.js b/app/assets/javascripts/analytics/devops_report/devops_score.js index 18f7cf0c3ab..0bf98b65ed5 100644 --- a/app/assets/javascripts/analytics/devops_report/devops_score.js +++ b/app/assets/javascripts/analytics/devops_report/devops_score.js @@ -6,14 +6,14 @@ export default () => { if (!el) return false; - const { devopsScoreMetrics, devopsReportDocsPath, noDataImagePath } = el.dataset; + const { devopsScoreMetrics, noDataImagePath, devopsScoreIntroImagePath } = el.dataset; return new Vue({ el, provide: { devopsScoreMetrics: JSON.parse(devopsScoreMetrics), - devopsReportDocsPath, noDataImagePath, + devopsScoreIntroImagePath, }, render(h) { return h(DevopsScore); diff --git a/app/assets/javascripts/analytics/devops_report/devops_score_disabled_service_ping.js b/app/assets/javascripts/analytics/devops_report/devops_score_disabled_service_ping.js index 63b36f35247..eb2992422a4 100644 --- a/app/assets/javascripts/analytics/devops_report/devops_score_disabled_service_ping.js +++ b/app/assets/javascripts/analytics/devops_report/devops_score_disabled_service_ping.js @@ -11,12 +11,7 @@ export default () => { if (!emptyStateContainer) return false; - const { - isAdmin, - emptyStateSvgPath, - enableServicePingPath, - docsLink, - } = emptyStateContainer.dataset; + const { isAdmin, emptyStateSvgPath, enableServicePingPath } = emptyStateContainer.dataset; return new Vue({ el: emptyStateContainer, @@ -24,7 +19,6 @@ export default () => { isAdmin: parseBoolean(isAdmin), svgPath: emptyStateSvgPath, primaryButtonPath: enableServicePingPath, - docsLink, }, render(h) { return h(ServicePingDisabled); diff --git a/app/assets/javascripts/api/analytics_api.js b/app/assets/javascripts/api/analytics_api.js index fd9b0160b0d..c7a53288ae4 100644 --- a/app/assets/javascripts/api/analytics_api.js +++ b/app/assets/javascripts/api/analytics_api.js @@ -1,51 +1,83 @@ import axios from '~/lib/utils/axios_utils'; +import { joinPaths } from '~/lib/utils/url_utility'; import { buildApiUrl } from './api_utils'; -const GROUP_VSA_PATH_BASE = - '/groups/:id/-/analytics/value_stream_analytics/value_streams/:value_stream_id/stages/:stage_id'; -const PROJECT_VSA_PATH_BASE = '/:project_path/-/analytics/value_stream_analytics/value_streams'; +const PROJECT_VSA_METRICS_BASE = '/:request_path/-/analytics/value_stream_analytics'; +const PROJECT_VSA_PATH_BASE = '/:request_path/-/analytics/value_stream_analytics/value_streams'; const PROJECT_VSA_STAGES_PATH = `${PROJECT_VSA_PATH_BASE}/:value_stream_id/stages`; +const PROJECT_VSA_STAGE_DATA_PATH = `${PROJECT_VSA_STAGES_PATH}/:stage_id`; -const buildProjectValueStreamPath = (projectPath, valueStreamId = null) => { +export const METRIC_TYPE_SUMMARY = 'summary'; +export const METRIC_TYPE_TIME_SUMMARY = 'time_summary'; + +const buildProjectMetricsPath = (requestPath) => + buildApiUrl(PROJECT_VSA_METRICS_BASE).replace(':request_path', requestPath); + +const buildProjectValueStreamPath = (requestPath, valueStreamId = null) => { if (valueStreamId) { return buildApiUrl(PROJECT_VSA_STAGES_PATH) - .replace(':project_path', projectPath) + .replace(':request_path', requestPath) .replace(':value_stream_id', valueStreamId); } - return buildApiUrl(PROJECT_VSA_PATH_BASE).replace(':project_path', projectPath); + return buildApiUrl(PROJECT_VSA_PATH_BASE).replace(':request_path', requestPath); }; -const buildGroupValueStreamPath = ({ groupId, valueStreamId = null, stageId = null }) => - buildApiUrl(GROUP_VSA_PATH_BASE) - .replace(':id', groupId) +const buildValueStreamStageDataPath = ({ requestPath, valueStreamId = null, stageId = null }) => + buildApiUrl(PROJECT_VSA_STAGE_DATA_PATH) + .replace(':request_path', requestPath) .replace(':value_stream_id', valueStreamId) .replace(':stage_id', stageId); -export const getProjectValueStreams = (projectPath) => { - const url = buildProjectValueStreamPath(projectPath); +export const getProjectValueStreams = (requestPath) => { + const url = buildProjectValueStreamPath(requestPath); return axios.get(url); }; -export const getProjectValueStreamStages = (projectPath, valueStreamId) => { - const url = buildProjectValueStreamPath(projectPath, valueStreamId); +export const getProjectValueStreamStages = (requestPath, valueStreamId) => { + const url = buildProjectValueStreamPath(requestPath, valueStreamId); return axios.get(url); }; // NOTE: legacy VSA request use a different path // the `requestPath` provides a full url for the request export const getProjectValueStreamStageData = ({ requestPath, stageId, params }) => - axios.get(`${requestPath}/events/${stageId}`, { params }); + axios.get(joinPaths(requestPath, 'events', stageId), { params }); export const getProjectValueStreamMetrics = (requestPath, params) => axios.get(requestPath, { params }); /** - * Shared group VSA paths - * We share some endpoints across and group and project level VSA - * When used for project level VSA, requests should include the `project_id` in the params object + * Dedicated project VSA paths */ -export const getValueStreamStageMedian = ({ groupId, valueStreamId, stageId }, params = {}) => { - const stageBase = buildGroupValueStreamPath({ groupId, valueStreamId, stageId }); - return axios.get(`${stageBase}/median`, { params }); +export const getValueStreamStageMedian = ({ requestPath, valueStreamId, stageId }, params = {}) => { + const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId }); + return axios.get(joinPaths(stageBase, 'median'), { params }); +}; + +export const getValueStreamStageRecords = ( + { requestPath, valueStreamId, stageId }, + params = {}, +) => { + const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId }); + return axios.get(joinPaths(stageBase, 'records'), { params }); +}; + +export const getValueStreamStageCounts = ({ requestPath, valueStreamId, stageId }, params = {}) => { + const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId }); + return axios.get(joinPaths(stageBase, 'count'), { params }); +}; + +export const getValueStreamMetrics = ({ + endpoint = METRIC_TYPE_SUMMARY, + requestPath, + params = {}, +}) => { + const metricBase = buildProjectMetricsPath(requestPath); + return axios.get(joinPaths(metricBase, endpoint), { params }); +}; + +export const getValueStreamSummaryMetrics = (requestPath, params = {}) => { + const metricBase = buildProjectMetricsPath(requestPath); + return axios.get(joinPaths(metricBase, 'summary'), { params }); }; diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js index 7934eac2f7e..4698fcd4d42 100644 --- a/app/assets/javascripts/behaviors/markdown/render_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -11,7 +11,7 @@ import renderMetrics from './render_metrics'; // Delegates to syntax highlight and render math & mermaid diagrams. // $.fn.renderGFM = function renderGFM() { - syntaxHighlight(this.find('.js-syntax-highlight')); + syntaxHighlight(this.find('.js-syntax-highlight').get()); renderMath(this.find('.js-render-math')); renderMermaid(this.find('.js-render-mermaid')); highlightCurrentUser(this.find('.gfm-project_member').get()); diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue index 9c023235428..4742b4ae4b4 100644 --- a/app/assets/javascripts/blob/components/blob_header.vue +++ b/app/assets/javascripts/blob/components/blob_header.vue @@ -25,6 +25,11 @@ export default { required: false, default: false, }, + isBinary: { + type: Boolean, + required: false, + default: false, + }, activeViewerType: { type: String, required: false, @@ -81,6 +86,7 @@ export default { :raw-path="blob.rawPath" :active-viewer="viewer" :has-render-error="hasRenderError" + :is-binary="isBinary" @copy="proxyCopyRequest" /> </div> diff --git a/app/assets/javascripts/blob/components/blob_header_default_actions.vue b/app/assets/javascripts/blob/components/blob_header_default_actions.vue index b9f2c5b42e4..2798a918b15 100644 --- a/app/assets/javascripts/blob/components/blob_header_default_actions.vue +++ b/app/assets/javascripts/blob/components/blob_header_default_actions.vue @@ -32,6 +32,11 @@ export default { required: false, default: false, }, + isBinary: { + type: Boolean, + required: false, + default: false, + }, }, computed: { downloadUrl() { @@ -43,6 +48,9 @@ export default { getBlobHashTarget() { return `[data-blob-hash="${this.blobHash}"]`; }, + showCopyButton() { + return !this.hasRenderError && !this.isBinary; + }, }, BTN_COPY_CONTENTS_TITLE, BTN_DOWNLOAD_TITLE, @@ -52,7 +60,7 @@ export default { <template> <gl-button-group data-qa-selector="default_actions_container"> <gl-button - v-if="!hasRenderError" + v-if="showCopyButton" v-gl-tooltip.hover :aria-label="$options.BTN_COPY_CONTENTS_TITLE" :title="$options.BTN_COPY_CONTENTS_TITLE" @@ -65,6 +73,7 @@ export default { variant="default" /> <gl-button + v-if="!isBinary" v-gl-tooltip.hover :aria-label="$options.BTN_RAW_TITLE" :title="$options.BTN_RAW_TITLE" diff --git a/app/assets/javascripts/blob/csv/csv_viewer.vue b/app/assets/javascripts/blob/csv/csv_viewer.vue index 050f2785d9a..1f9d20a487f 100644 --- a/app/assets/javascripts/blob/csv/csv_viewer.vue +++ b/app/assets/javascripts/blob/csv/csv_viewer.vue @@ -1,11 +1,12 @@ <script> -import { GlAlert, GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { GlLoadingIcon, GlTable } from '@gitlab/ui'; import Papa from 'papaparse'; +import PapaParseAlert from '~/vue_shared/components/papa_parse_alert.vue'; export default { components: { + PapaParseAlert, GlTable, - GlAlert, GlLoadingIcon, }, props: { @@ -17,7 +18,7 @@ export default { data() { return { items: [], - errorMessage: null, + papaParseErrors: [], loading: true, }; }, @@ -26,7 +27,7 @@ export default { this.items = parsed.data; if (parsed.errors.length) { - this.errorMessage = parsed.errors.map((e) => e.message).join('. '); + this.papaParseErrors = parsed.errors; } this.loading = false; @@ -40,9 +41,7 @@ export default { <gl-loading-icon class="gl-mt-5" size="lg" /> </div> <div v-else> - <gl-alert v-if="errorMessage" variant="danger" :dismissible="false"> - {{ errorMessage }} - </gl-alert> + <papa-parse-alert v-if="papaParseErrors.length" :papa-parse-errors="papaParseErrors" /> <gl-table :empty-text="__('No CSV data to display.')" :items="items" diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index 4d133659daa..1bda7d4e3f0 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -36,6 +36,34 @@ const loadRichBlobViewer = (type) => { } }; +const loadViewer = (viewerParam) => { + const viewer = viewerParam; + const url = viewer.getAttribute('data-url'); + + if (!url || viewer.getAttribute('data-loaded') || viewer.getAttribute('data-loading')) { + return Promise.resolve(viewer); + } + + viewer.setAttribute('data-loading', 'true'); + + return axios.get(url).then(({ data }) => { + viewer.innerHTML = data.html; + + window.requestIdleCallback(() => { + viewer.removeAttribute('data-loading'); + }); + + return viewer; + }); +}; + +export const initAuxiliaryViewer = () => { + const auxiliaryViewer = document.querySelector('.blob-viewer[data-type="auxiliary"]'); + if (!auxiliaryViewer) return; + + loadViewer(auxiliaryViewer); +}; + export const handleBlobRichViewer = (viewer, type) => { if (!viewer || !type) return; @@ -49,27 +77,20 @@ export const handleBlobRichViewer = (viewer, type) => { }); }; -export default class BlobViewer { +export class BlobViewer { constructor() { performanceMarkAndMeasure({ mark: REPO_BLOB_LOAD_VIEWER_START, }); const viewer = document.querySelector('.blob-viewer[data-type="rich"]'); const type = viewer?.dataset?.richType; - BlobViewer.initAuxiliaryViewer(); + initAuxiliaryViewer(); handleBlobRichViewer(viewer, type); this.initMainViewers(); } - static initAuxiliaryViewer() { - const auxiliaryViewer = document.querySelector('.blob-viewer[data-type="auxiliary"]'); - if (!auxiliaryViewer) return; - - BlobViewer.loadViewer(auxiliaryViewer); - } - initMainViewers() { this.$fileHolder = $('.file-holder'); if (!this.$fileHolder.length) return; @@ -173,7 +194,7 @@ export default class BlobViewer { this.activeViewer = newViewer; this.toggleCopyButtonState(); - BlobViewer.loadViewer(newViewer) + loadViewer(newViewer) .then((viewer) => { $(viewer).renderGFM(); window.requestIdleCallback(() => { @@ -205,25 +226,4 @@ export default class BlobViewer { }), ); } - - static loadViewer(viewerParam) { - const viewer = viewerParam; - const url = viewer.getAttribute('data-url'); - - if (!url || viewer.getAttribute('data-loaded') || viewer.getAttribute('data-loading')) { - return Promise.resolve(viewer); - } - - viewer.setAttribute('data-loading', 'true'); - - return axios.get(url).then(({ data }) => { - viewer.innerHTML = data.html; - - window.requestIdleCallback(() => { - viewer.removeAttribute('data-loading'); - }); - - return viewer; - }); - } } diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index 7bfda46d71c..e068910c626 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext'; import SourceEditor from '~/editor/source_editor'; +import { getBlobLanguage } from '~/editor/utils'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown'; @@ -16,16 +17,7 @@ export default class EditBlob { this.configureMonacoEditor(); if (this.options.isMarkdown) { - import('~/editor/extensions/source_editor_markdown_ext') - .then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => { - this.editor.use(new MarkdownExtension()); - addEditorMarkdownListeners(this.editor); - }) - .catch((e) => - createFlash({ - message: `${BLOB_EDITOR_ERROR}: ${e}`, - }), - ); + this.fetchMarkdownExtension(); } this.initModePanesAndLinks(); @@ -34,12 +26,30 @@ export default class EditBlob { this.editor.focus(); } + fetchMarkdownExtension() { + import('~/editor/extensions/source_editor_markdown_ext') + .then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => { + this.editor.use( + new MarkdownExtension({ instance: this.editor, projectPath: this.options.projectPath }), + ); + this.hasMarkdownExtension = true; + addEditorMarkdownListeners(this.editor); + }) + .catch((e) => + createFlash({ + message: `${BLOB_EDITOR_ERROR}: ${e}`, + }), + ); + } + configureMonacoEditor() { const editorEl = document.getElementById('editor'); const fileNameEl = document.getElementById('file_path') || document.getElementById('file_name'); const fileContentEl = document.getElementById('file-content'); const form = document.querySelector('.js-edit-blob-form'); + this.hasMarkdownExtension = false; + const rootEditor = new SourceEditor(); this.editor = rootEditor.createInstance({ @@ -51,6 +61,12 @@ export default class EditBlob { fileNameEl.addEventListener('change', () => { this.editor.updateModelLanguage(fileNameEl.value); + const newLang = getBlobLanguage(fileNameEl.value); + if (newLang === 'markdown') { + if (!this.hasMarkdownExtension) { + this.fetchMarkdownExtension(); + } + } }); form.addEventListener('submit', () => { diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 46f97e09385..3219d74f85f 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -204,6 +204,9 @@ export const FiltersInfo = { releaseTag: { negatedSupport: true, }, + types: { + negatedSupport: true, + }, search: { negatedSupport: false, }, diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index 05b64ddc773..5658a34e9a6 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -65,7 +65,7 @@ export default { }, computed: { ...mapState(['isShowingLabels', 'issuableType', 'allowSubEpics']), - ...mapGetters(['isEpicBoard']), + ...mapGetters(['isEpicBoard', 'isProjectBoard']), cappedAssignees() { // e.g. maxRender is 4, // Render up to all 4 assignees if there are only 4 assigness @@ -144,6 +144,9 @@ export default { totalProgress() { return Math.round((this.item.descendantWeightSum.closedIssues / this.totalWeight) * 100); }, + showReferencePath() { + return !this.isProjectBoard && this.itemReferencePath; + }, }, methods: { ...mapActions(['performSearch', 'setError']), @@ -247,7 +250,7 @@ export default { :class="{ 'gl-font-base': isEpicBoard }" > <tooltip-on-truncate - v-if="itemReferencePath" + v-if="showReferencePath" :title="itemReferencePath" placement="bottom" class="board-item-path gl-text-truncate gl-font-weight-bold" diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index 69abf886ad7..bcf5b12b209 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -79,7 +79,7 @@ export default { 'is-collapsed': list.collapsed, 'board-type-assignee': list.listType === 'assignee', }" - :data-id="list.id" + :data-list-id="list.id" class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal is-expandable" data-qa-selector="board_list" > diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 53b071aaed1..4df6ff75249 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -6,10 +6,12 @@ import { mapState, mapGetters, mapActions } from 'vuex'; import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue'; import defaultSortableConfig from '~/sortable/sortable_config'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { DraggableItemTypes } from '../constants'; import BoardColumn from './board_column.vue'; import BoardColumnDeprecated from './board_column_deprecated.vue'; export default { + draggableItemTypes: DraggableItemTypes, components: { BoardAddNewColumn, BoardColumn, @@ -76,19 +78,6 @@ export default { const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list; el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' }); }, - handleDragOnEnd(params) { - const { item, newIndex, oldIndex, to } = params; - - const listId = item.dataset.id; - const replacedListId = to.children[newIndex].dataset.id; - - this.moveList({ - listId, - replacedListId, - newIndex, - adjustmentValue: newIndex < oldIndex ? 1 : -1, - }); - }, }, }; </script> @@ -104,7 +93,7 @@ export default { ref="list" v-bind="draggableOptions" class="boards-list gl-w-full gl-py-5 gl-px-3 gl-white-space-nowrap" - @end="handleDragOnEnd" + @end="moveList" > <component :is="boardColumnComponent" @@ -112,6 +101,7 @@ export default { :key="index" ref="board" :list="list" + :data-draggable-item-type="$options.draggableItemTypes.list" :disabled="disabled" /> diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index e014b82d362..7a936e75676 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -87,6 +87,7 @@ export default { v-bind="$attrs" :open="isSidebarOpen" class="boards-sidebar gl-absolute" + variant="sidebar" @close="handleClose" > <template #title> @@ -159,7 +160,7 @@ export default { :issuable-type="issuableType" data-testid="sidebar-due-date" /> - <board-sidebar-labels-select class="labels" /> + <board-sidebar-labels-select class="block labels" /> <sidebar-weight-widget v-if="weightFeatureAvailable" :iid="activeBoardItem.iid" diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index cfd6b21fa66..7f242dea644 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -27,7 +27,15 @@ export default { }, computed: { urlParams() { - const { authorUsername, labelName, assigneeUsername, search } = this.filterParams; + const { + authorUsername, + labelName, + assigneeUsername, + search, + milestoneTitle, + types, + weight, + } = this.filterParams; let notParams = {}; if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) { @@ -36,6 +44,9 @@ export default { 'not[label_name][]': this.filterParams.not.labelName, 'not[author_username]': this.filterParams.not.authorUsername, 'not[assignee_username]': this.filterParams.not.assigneeUsername, + 'not[types]': this.filterParams.not.types, + 'not[milestone_title]': this.filterParams.not.milestoneTitle, + 'not[weight]': this.filterParams.not.weight, }, undefined, ); @@ -46,7 +57,10 @@ export default { author_username: authorUsername, 'label_name[]': labelName, assignee_username: assigneeUsername, + milestone_title: milestoneTitle, search, + types, + weight, }; }, }, @@ -64,7 +78,15 @@ export default { this.performSearch(); }, getFilteredSearchValue() { - const { authorUsername, labelName, assigneeUsername, search } = this.filterParams; + const { + authorUsername, + labelName, + assigneeUsername, + search, + milestoneTitle, + types, + weight, + } = this.filterParams; const filteredSearchValue = []; if (authorUsername) { @@ -81,6 +103,13 @@ export default { }); } + if (types) { + filteredSearchValue.push({ + type: 'types', + value: { data: types, operator: '=' }, + }); + } + if (labelName?.length) { filteredSearchValue.push( ...labelName.map((label) => ({ @@ -90,6 +119,20 @@ export default { ); } + if (milestoneTitle) { + filteredSearchValue.push({ + type: 'milestone_title', + value: { data: milestoneTitle, operator: '=' }, + }); + } + + if (weight) { + filteredSearchValue.push({ + type: 'weight', + value: { data: weight, operator: '=' }, + }); + } + if (this.filterParams['not[authorUsername]']) { filteredSearchValue.push({ type: 'author_username', @@ -97,6 +140,20 @@ export default { }); } + if (this.filterParams['not[milestoneTitle]']) { + filteredSearchValue.push({ + type: 'milestone_title', + value: { data: this.filterParams['not[milestoneTitle]'], operator: '!=' }, + }); + } + + if (this.filterParams['not[weight]']) { + filteredSearchValue.push({ + type: 'weight', + value: { data: this.filterParams['not[weight]'], operator: '!=' }, + }); + } + if (this.filterParams['not[assigneeUsername]']) { filteredSearchValue.push({ type: 'assignee_username', @@ -113,6 +170,13 @@ export default { ); } + if (this.filterParams['not[types]']) { + filteredSearchValue.push({ + type: 'types', + value: { data: this.filterParams['not[types]'], operator: '!=' }, + }); + } + if (search) { filteredSearchValue.push(search); } @@ -140,9 +204,18 @@ export default { case 'assignee_username': filterParams.assigneeUsername = filter.value.data; break; + case 'types': + filterParams.types = filter.value.data; + break; case 'label_name': labels.push(filter.value.data); break; + case 'milestone_title': + filterParams.milestoneTitle = filter.value.data; + break; + case 'weight': + filterParams.weight = filter.value.data; + break; case 'filtered-search-term': if (filter.value.data) plainText.push(filter.value.data); break; diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 386ed6bd0a1..a89f71504a9 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -2,7 +2,7 @@ import { GlModal, GlAlert } from '@gitlab/ui'; import { mapGetters, mapActions, mapState } from 'vuex'; import ListLabel from '~/boards/models/label'; -import { TYPE_ITERATION, TYPE_MILESTONE, TYPE_USER } from '~/graphql_shared/constants'; +import { TYPE_ITERATION, TYPE_MILESTONE } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { getParameterByName, visitUrl } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; @@ -18,10 +18,9 @@ const boardDefaults = { id: false, name: '', labels: [], - milestone_id: undefined, + milestone: {}, iteration_id: undefined, assignee: {}, - assignee_id: undefined, weight: null, hide_backlog_list: false, hide_closed_list: false, @@ -190,13 +189,10 @@ export default { issueBoardScopeMutationVariables() { return { weight: this.board.weight, - assigneeId: this.board.assignee?.id - ? convertToGraphQLId(TYPE_USER, this.board.assignee.id) + assigneeId: this.board.assignee?.id || null, + milestoneId: this.board.milestone?.id + ? convertToGraphQLId(TYPE_MILESTONE, this.board.milestone.id) : null, - milestoneId: - this.board.milestone?.id || this.board.milestone?.id === 0 - ? convertToGraphQLId(TYPE_MILESTONE, this.board.milestone.id) - : null, iterationId: this.board.iteration_id ? convertToGraphQLId(TYPE_ITERATION, this.board.iteration_id) : null, @@ -306,6 +302,19 @@ export default { } }); }, + setAssignee(assigneeId) { + this.$set(this.board, 'assignee', { + id: assigneeId, + }); + }, + setMilestone(milestoneId) { + this.$set(this.board, 'milestone', { + id: milestoneId, + }); + }, + setWeight(weight) { + this.$set(this.board, 'weight', weight); + }, }, }; </script> @@ -373,6 +382,9 @@ export default { :weights="weights" @set-iteration="setIteration" @set-board-labels="setBoardLabels" + @set-assignee="setAssignee" + @set-milestone="setMilestone" + @set-weight="setWeight" /> </form> </gl-modal> diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 8dca6be853f..849492effab 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -6,12 +6,13 @@ import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_opt import { sprintf, __ } from '~/locale'; import defaultSortableConfig from '~/sortable/sortable_config'; import Tracking from '~/tracking'; -import { toggleFormEventPrefix } from '../constants'; +import { toggleFormEventPrefix, DraggableItemTypes } from '../constants'; import eventHub from '../eventhub'; import BoardCard from './board_card.vue'; import BoardNewIssue from './board_new_issue.vue'; export default { + draggableItemTypes: DraggableItemTypes, name: 'BoardList', i18n: { loading: __('Loading'), @@ -27,11 +28,6 @@ export default { GlIntersectionObserver, }, mixins: [Tracking.mixin()], - inject: { - canAdminList: { - default: false, - }, - }, props: { disabled: { type: Boolean, @@ -89,8 +85,8 @@ export default { return !this.isEpicBoard && this.list.listType !== 'closed' && this.showIssueForm; }, listRef() { - // When list is draggable, the reference to the list needs to be accessed differently - return this.canAdminList ? this.$refs.list.$el : this.$refs.list; + // When list is draggable, the reference to the list needs to be accessed differently + return this.canMoveIssue ? this.$refs.list.$el : this.$refs.list; }, showingAllItems() { return this.boardItems.length === this.listItemsCount; @@ -100,8 +96,11 @@ export default { ? this.$options.i18n.showingAllEpics : this.$options.i18n.showingAllIssues; }, + canMoveIssue() { + return !this.disabled; + }, treeRootWrapper() { - return this.canAdminList && !this.listsFlags[this.list.id]?.addItemToListInProgress + return this.canMoveIssue && !this.listsFlags[this.list.id]?.addItemToListInProgress ? Draggable : 'ul'; }, @@ -116,7 +115,7 @@ export default { value: this.boardItems, }; - return this.canAdminList ? options : {}; + return this.canMoveIssue ? options : {}; }, }, watch: { @@ -172,15 +171,33 @@ export default { this.loadNextPage(); } }, - handleDragOnStart() { + handleDragOnStart({ + item: { + dataset: { draggableItemType }, + }, + }) { + if (draggableItemType !== DraggableItemTypes.card) { + return; + } + sortableStart(); this.track('drag_card', { label: 'board' }); }, - handleDragOnEnd(params) { + handleDragOnEnd({ + newIndex: originalNewIndex, + oldIndex, + from, + to, + item: { + dataset: { draggableItemType, itemId, itemIid, itemPath }, + }, + }) { + if (draggableItemType !== DraggableItemTypes.card) { + return; + } + sortableEnd(); - const { oldIndex, from, to, item } = params; - let { newIndex } = params; - const { itemId, itemIid, itemPath } = item.dataset; + let newIndex = originalNewIndex; let { children } = to; let moveBeforeId; let moveAfterId; @@ -267,6 +284,7 @@ export default { :index="index" :list="list" :item="item" + :data-draggable-item-type="$options.draggableItemTypes.card" :disabled="disabled" /> <gl-intersection-observer @appear="onReachingListBottom"> diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index caeecb25227..84c9191975e 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -1,21 +1,19 @@ <script> -import { GlButton } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import { getMilestone } from 'ee_else_ce/boards/boards_util'; import BoardNewIssueMixin from 'ee_else_ce/boards/mixins/board_new_issue'; -import { __ } from '~/locale'; + import { toggleFormEventPrefix } from '../constants'; import eventHub from '../eventhub'; + +import BoardNewItem from './board_new_item.vue'; import ProjectSelect from './project_select.vue'; export default { name: 'BoardNewIssue', - i18n: { - cancel: __('Cancel'), - }, components: { + BoardNewItem, ProjectSelect, - GlButton, }, mixins: [BoardNewIssueMixin], inject: ['groupId'], @@ -25,106 +23,55 @@ export default { required: true, }, }, - data() { - return { - title: '', - }; - }, computed: { - ...mapState(['selectedProject']), - ...mapGetters(['isGroupBoard', 'isEpicBoard']), - /** - * We've extended this component in EE where - * submitButtonTitle returns a different string - * hence this is kept as a computed prop. - */ - submitButtonTitle() { - return __('Create issue'); + ...mapState(['selectedProject', 'fullPath']), + ...mapGetters(['isGroupBoard']), + formEventPrefix() { + return toggleFormEventPrefix.issue; }, - disabled() { - if (this.isGroupBoard) { - return this.title === '' || !this.selectedProject.name; - } - return this.title === ''; + disableSubmit() { + return this.isGroupBoard ? !this.selectedProject.name : false; }, - inputFieldId() { - // eslint-disable-next-line @gitlab/require-i18n-strings - return `${this.list.id}-title`; + projectPath() { + return this.isGroupBoard ? this.selectedProject.fullPath : this.fullPath; }, }, - mounted() { - this.$refs.input.focus(); - eventHub.$on('setSelectedProject', this.setSelectedProject); - }, methods: { ...mapActions(['addListNewIssue']), - submit() { - const { title } = this; + submit({ title }) { const labels = this.list.label ? [this.list.label] : []; const assignees = this.list.assignee ? [this.list.assignee] : []; const milestone = getMilestone(this.list); - eventHub.$emit(`scroll-board-list-${this.list.id}`); - return this.addListNewIssue({ + list: this.list, issueInput: { title, labelIds: labels?.map((l) => l.id), assigneeIds: assignees?.map((a) => a?.id), milestoneId: milestone?.id, - projectPath: this.selectedProject.fullPath, - ...this.extraIssueInput(), + projectPath: this.projectPath, }, - list: this.list, }).then(() => { - this.reset(); + this.cancel(); }); }, - reset() { - this.title = ''; - eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`); + cancel() { + eventHub.$emit(`${this.formEventPrefix}${this.list.id}`); }, }, }; </script> <template> - <div class="board-new-issue-form"> - <div class="board-card position-relative p-3 rounded"> - <form ref="submitForm" @submit.prevent="submit"> - <label :for="inputFieldId" class="label-bold">{{ __('Title') }}</label> - <input - :id="inputFieldId" - ref="input" - v-model="title" - class="form-control" - type="text" - name="issue_title" - autocomplete="off" - /> - <project-select v-if="isGroupBoard && !isEpicBoard" :group-id="groupId" :list="list" /> - <div class="clearfix gl-mt-3"> - <gl-button - ref="submitButton" - :disabled="disabled" - class="float-left js-no-auto-disable" - variant="confirm" - category="primary" - type="submit" - > - {{ submitButtonTitle }} - </gl-button> - <gl-button - ref="cancelButton" - class="float-right" - type="button" - variant="default" - @click="reset" - > - {{ $options.i18n.cancel }} - </gl-button> - </div> - </form> - </div> - </div> + <board-new-item + :list="list" + :form-event-prefix="formEventPrefix" + :submit-button-title="__('Create issue')" + :disable-submit="disableSubmit" + @form-submit="submit" + @form-cancel="cancel" + > + <project-select v-if="isGroupBoard" :group-id="groupId" :list="list" /> + </board-new-item> </template> diff --git a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue index 1218941065f..a25b436b8de 100644 --- a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue @@ -11,7 +11,7 @@ import ProjectSelect from './project_select_deprecated.vue'; // This component is being replaced in favor of './board_new_issue.vue' for GraphQL boards export default { - name: 'BoardNewIssue', + name: 'BoardNewIssueDeprecated', components: { ProjectSelect, GlButton, diff --git a/app/assets/javascripts/boards/components/board_new_item.vue b/app/assets/javascripts/boards/components/board_new_item.vue new file mode 100644 index 00000000000..44574de17d7 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_new_item.vue @@ -0,0 +1,95 @@ +<script> +import { GlForm, GlFormInput, GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +import eventHub from '../eventhub'; + +export default { + i18n: { + cancel: __('Cancel'), + }, + components: { + GlForm, + GlFormInput, + GlButton, + }, + props: { + list: { + type: Object, + required: true, + }, + formEventPrefix: { + type: String, + required: true, + }, + disableSubmit: { + type: Boolean, + required: false, + default: false, + }, + submitButtonTitle: { + type: String, + required: false, + default: __('Create issue'), + }, + }, + data() { + return { + title: '', + }; + }, + computed: { + inputFieldId() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `${this.list.id}-title`; + }, + }, + methods: { + handleFormCancel() { + this.title = ''; + this.$emit('form-cancel'); + }, + handleFormSubmit() { + const { title, list } = this; + + eventHub.$emit(`scroll-board-list-${this.list.id}`); + this.$emit('form-submit', { + title, + list, + }); + }, + }, +}; +</script> + +<template> + <div class="board-new-issue-form"> + <div class="board-card position-relative gl-p-5 rounded"> + <gl-form @submit.prevent="handleFormSubmit" @reset="handleFormCancel"> + <label :for="inputFieldId" class="gl-font-weight-bold">{{ __('Title') }}</label> + <gl-form-input + :id="inputFieldId" + v-model.trim="title" + :autofocus="true" + autocomplete="off" + type="text" + name="issue_title" + /> + <slot></slot> + <div class="gl-clearfix gl-mt-4"> + <gl-button + :disabled="!title || disableSubmit" + class="gl-float-left js-no-auto-disable" + variant="confirm" + type="submit" + > + {{ submitButtonTitle }} + </gl-button> + <gl-button class="gl-float-right js-no-auto-disable" type="reset"> + {{ $options.i18n.cancel }} + </gl-button> + </div> + </gl-form> + </div> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue index d8dac17d326..5206db05410 100644 --- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue @@ -1,4 +1,6 @@ <script> +import { GlFilteredSearchToken } from '@gitlab/ui'; +import { mapActions } from 'vuex'; import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue'; import issueBoardFilters from '~/boards/issue_board_filters'; import { TYPE_USER } from '~/graphql_shared/constants'; @@ -6,13 +8,24 @@ import { convertToGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; +import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; +import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue'; export default { + types: { + ISSUE: 'ISSUE', + INCIDENT: 'INCIDENT', + }, i18n: { search: __('Search'), label: __('Label'), author: __('Author'), assignee: __('Assignee'), + type: __('Type'), + incident: __('Incident'), + issue: __('Issue'), + milestone: __('Milestone'), + weight: __('Weight'), is: __('is'), isNot: __('is not'), }, @@ -29,7 +42,19 @@ export default { }, computed: { tokens() { - const { label, is, isNot, author, assignee } = this.$options.i18n; + const { + label, + is, + isNot, + author, + assignee, + issue, + incident, + type, + milestone, + weight, + } = this.$options.i18n; + const { types } = this.$options; const { fetchAuthors, fetchLabels } = issueBoardFilters( this.$apollo, this.fullPath, @@ -77,10 +102,40 @@ export default { fetchAuthors, preloadedAuthors: this.preloadedAuthors(), }, + { + icon: 'issues', + title: type, + type: 'types', + operators: [{ value: '=', description: is }], + token: GlFilteredSearchToken, + unique: true, + options: [ + { icon: 'issue-type-issue', value: types.ISSUE, title: issue }, + { icon: 'issue-type-incident', value: types.INCIDENT, title: incident }, + ], + }, + { + type: 'milestone_title', + title: milestone, + icon: 'clock', + symbol: '%', + token: MilestoneToken, + unique: true, + defaultMilestones: [], // todo: https://gitlab.com/gitlab-org/gitlab/-/issues/337044#note_640010094 + fetchMilestones: this.fetchMilestones, + }, + { + type: 'weight', + title: weight, + icon: 'weight', + token: WeightToken, + unique: true, + }, ]; }, }, methods: { + ...mapActions(['fetchMilestones']), preloadedAuthors() { return gon?.current_user_id ? [ diff --git a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue index 84802650dad..e7696b8d31b 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue @@ -87,7 +87,7 @@ export default { <div> <header v-show="showHeader" - class="gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-mb-3" + class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-mb-2" > <span class="gl-vertical-align-middle"> <slot name="title"> @@ -97,7 +97,8 @@ export default { </span> <gl-button v-if="canUpdate" - variant="link" + category="tertiary" + size="small" class="gl-text-gray-900! gl-ml-5 js-sidebar-dropdown-toggle edit-link" data-testid="edit-button" @click="toggle" diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue index 29febd0fa51..e74463825c5 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue @@ -25,6 +25,8 @@ export default { data() { return { loading: false, + oldIid: null, + isEditing: false, }; }, computed: { @@ -72,6 +74,15 @@ export default { return this.labelsFetchPath || projectLabelsFetchPath; }, }, + watch: { + activeBoardItem(_, oldVal) { + if (this.isEditing) { + this.oldIid = oldVal.iid; + } else { + this.oldIid = null; + } + }, + }, methods: { ...mapActions(['setActiveBoardItemLabels', 'setError']), async setLabels(payload) { @@ -84,8 +95,14 @@ export default { .filter((label) => !payload.find((selected) => selected.id === label.id)) .map((label) => label.id); - const input = { addLabelIds, removeLabelIds, projectPath: this.projectPathForActiveIssue }; + const input = { + addLabelIds, + removeLabelIds, + projectPath: this.projectPathForActiveIssue, + iid: this.oldIid, + }; await this.setActiveBoardItemLabels(input); + this.oldIid = null; } catch (e) { this.setError({ error: e, message: __('An error occurred while updating labels.') }); } finally { @@ -115,6 +132,8 @@ export default { :title="__('Labels')" :loading="loading" data-testid="sidebar-labels" + @open="isEditing = true" + @close="isEditing = false" > <template #collapsed> <gl-label diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 21ef70582a4..16fb4596726 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -109,9 +109,16 @@ export const FilterFields = { 'myReactionEmoji', 'releaseTag', 'search', + 'types', + 'weight', ], }; +export const DraggableItemTypes = { + card: 'card', + list: 'list', +}; + export default { BoardType, ListType, diff --git a/app/assets/javascripts/boards/graphql/board_lists.query.graphql b/app/assets/javascripts/boards/graphql/board_lists.query.graphql index eb922f162f8..734867c77e9 100644 --- a/app/assets/javascripts/boards/graphql/board_lists.query.graphql +++ b/app/assets/javascripts/boards/graphql/board_lists.query.graphql @@ -9,6 +9,7 @@ query ListIssues( ) { group(fullPath: $fullPath) @include(if: $isGroup) { board(id: $boardId) { + hideBacklogList lists(issueFilters: $filters) { nodes { ...BoardListFragment @@ -18,6 +19,7 @@ query ListIssues( } project(fullPath: $fullPath) @include(if: $isProject) { board(id: $boardId) { + hideBacklogList lists(issueFilters: $filters) { nodes { ...BoardListFragment diff --git a/app/assets/javascripts/boards/graphql/group_board_members.query.graphql b/app/assets/javascripts/boards/graphql/group_board_members.query.graphql index 3b8c5389725..d3251c2aa12 100644 --- a/app/assets/javascripts/boards/graphql/group_board_members.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_board_members.query.graphql @@ -3,7 +3,7 @@ query GroupBoardMembers($fullPath: ID!, $search: String) { workspace: group(fullPath: $fullPath) { __typename - assignees: groupMembers(search: $search) { + assignees: groupMembers(search: $search, relations: [DIRECT, DESCENDANTS, INHERITED]) { __typename nodes { id diff --git a/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql new file mode 100644 index 00000000000..73aa9137dec --- /dev/null +++ b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql @@ -0,0 +1,10 @@ +query GroupBoardMilestones($fullPath: ID!, $searchTerm: String) { + group(fullPath: $fullPath) { + milestones(includeAncestors: true, searchTitle: $searchTerm) { + nodes { + id + title + } + } + } +} diff --git a/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql new file mode 100644 index 00000000000..8dd4d256caa --- /dev/null +++ b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql @@ -0,0 +1,10 @@ +query ProjectBoardMilestones($fullPath: ID!, $searchTerm: String) { + project(fullPath: $fullPath) { + milestones(searchTitle: $searchTerm, includeAncestors: true) { + nodes { + id + title + } + } + } +} diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js index 7f655091cd0..7d6179a8547 100644 --- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js +++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js @@ -11,7 +11,12 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient( + {}, + { + assumeImmutableResults: true, + }, + ), }); export default (params = {}) => { diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 0f1b72146c9..970d00841bd 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -1,4 +1,5 @@ import * as Sentry from '@sentry/browser'; +import { sortBy } from 'lodash'; import { BoardType, ListType, @@ -13,14 +14,14 @@ import { issuableTypes, FilterFields, ListTypeTitles, + DraggableItemTypes, } from 'ee_else_ce/boards/constants'; import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql'; import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -// eslint-disable-next-line import/no-deprecated -import { urlParamsToObject } from '~/lib/utils/url_utility'; +import { queryToObject } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import { formatBoardLists, @@ -35,10 +36,13 @@ import { filterVariables, } from '../boards_util'; import boardLabelsQuery from '../graphql/board_labels.query.graphql'; +import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql'; import groupProjectsQuery from '../graphql/group_projects.query.graphql'; import issueCreateMutation from '../graphql/issue_create.mutation.graphql'; import issueSetLabelsMutation from '../graphql/issue_set_labels.mutation.graphql'; import listsIssuesQuery from '../graphql/lists_issues.query.graphql'; +import projectBoardMilestonesQuery from '../graphql/project_board_milestones.query.graphql'; + import * as types from './mutation_types'; export const gqlClient = createGqClient( @@ -76,8 +80,7 @@ export default { performSearch({ dispatch }) { dispatch( 'setFilters', - // eslint-disable-next-line import/no-deprecated - convertObjectPropsToCamelCase(urlParamsToObject(window.location.search)), + convertObjectPropsToCamelCase(queryToObject(window.location.search, { gatherArrays: true })), ); if (gon.features.graphqlBoardLists) { @@ -215,34 +218,99 @@ export default { }); }, + fetchMilestones({ state, commit }, searchTerm) { + commit(types.RECEIVE_MILESTONES_REQUEST); + + const { fullPath, boardType } = state; + + const variables = { + fullPath, + searchTerm, + }; + + let query; + if (boardType === BoardType.project) { + query = projectBoardMilestonesQuery; + } + if (boardType === BoardType.group) { + query = groupBoardMilestonesQuery; + } + + if (!query) { + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('Unknown board type'); + } + + return gqlClient + .query({ + query, + variables, + }) + .then(({ data }) => { + const errors = data[boardType]?.errors; + const milestones = data[boardType]?.milestones.nodes; + + if (errors?.[0]) { + throw new Error(errors[0]); + } + + commit(types.RECEIVE_MILESTONES_SUCCESS, milestones); + + return milestones; + }) + .catch((e) => { + commit(types.RECEIVE_MILESTONES_FAILURE); + throw e; + }); + }, + moveList: ( - { state, commit, dispatch }, - { listId, replacedListId, newIndex, adjustmentValue }, + { state: { boardLists }, commit, dispatch }, + { + item: { + dataset: { listId: movedListId, draggableItemType }, + }, + newIndex, + to: { children }, + }, ) => { - if (listId === replacedListId) { + if (draggableItemType !== DraggableItemTypes.list) { return; } - const { boardLists } = state; - const backupList = { ...boardLists }; - const movedList = boardLists[listId]; + const displacedListId = children[newIndex].dataset.listId; + if (movedListId === displacedListId) { + return; + } - const newPosition = newIndex - 1; - const listAtNewIndex = boardLists[replacedListId]; + const listIds = sortBy( + Object.keys(boardLists).filter( + (listId) => + listId !== movedListId && + boardLists[listId].listType !== ListType.backlog && + boardLists[listId].listType !== ListType.closed, + ), + (i) => boardLists[i].position, + ); - movedList.position = newPosition; - listAtNewIndex.position += adjustmentValue; - commit(types.MOVE_LIST, { - movedList, - listAtNewIndex, - }); + const targetPosition = boardLists[displacedListId].position; + // When the dragged list moves left, displaced list should shift right. + const shiftOffset = Number(boardLists[movedListId].position < targetPosition); + const displacedListIndex = listIds.findIndex((listId) => listId === displacedListId); - dispatch('updateList', { listId, position: newPosition, backupList }); + commit( + types.MOVE_LISTS, + listIds + .slice(0, displacedListIndex + shiftOffset) + .concat([movedListId], listIds.slice(displacedListIndex + shiftOffset)) + .map((listId, index) => ({ listId, position: index })), + ); + dispatch('updateList', { listId: movedListId, position: targetPosition }); }, updateList: ( - { commit, state: { issuableType, boardItemsByListId = {} }, dispatch }, - { listId, position, collapsed, backupList }, + { state: { issuableType, boardItemsByListId = {} }, dispatch }, + { listId, position, collapsed }, ) => { gqlClient .mutate({ @@ -255,8 +323,7 @@ export default { }) .then(({ data }) => { if (data?.updateBoardList?.errors.length) { - commit(types.UPDATE_LIST_FAILURE, backupList); - return; + throw new Error(); } // Only fetch when board items havent been fetched on a collapsed list @@ -265,10 +332,19 @@ export default { } }) .catch(() => { - commit(types.UPDATE_LIST_FAILURE, backupList); + dispatch('handleUpdateListFailure'); }); }, + handleUpdateListFailure: ({ dispatch, commit }) => { + dispatch('fetchLists'); + + commit( + types.SET_ERROR, + s__('Boards|An error occurred while updating the board list. Please try again.'), + ); + }, + toggleListCollapsed: ({ commit }, { listId, collapsed }) => { commit(types.TOGGLE_LIST_COLLAPSED, { listId, collapsed }); }, @@ -551,7 +627,7 @@ export default { mutation: issueSetLabelsMutation, variables: { input: { - iid: String(activeBoardItem.iid), + iid: input.iid || String(activeBoardItem.iid), addLabelIds: input.addLabelIds ?? [], removeLabelIds: input.removeLabelIds ?? [], projectPath: input.projectPath, @@ -564,7 +640,7 @@ export default { } commit(types.UPDATE_BOARD_ITEM_BY_ID, { - itemId: activeBoardItem.id, + itemId: getIdFromGraphQLId(data.updateIssue?.issue?.id) || activeBoardItem.id, prop: 'labels', value: data.updateIssue.issue.labels.nodes, }); diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 49c40c7776a..857b0912c57 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -8,8 +8,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import createDefaultClient from '~/lib/graphql'; import axios from '~/lib/utils/axios_utils'; import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -// eslint-disable-next-line import/no-deprecated -import { mergeUrlParams, urlParamsToObject, getUrlParamsArray } from '~/lib/utils/url_utility'; +import { mergeUrlParams, queryToObject, getUrlParamsArray } from '~/lib/utils/url_utility'; import { ListType, flashAnimationDuration } from '../constants'; import eventHub from '../eventhub'; import ListAssignee from '../models/assignee'; @@ -597,8 +596,7 @@ const boardsStore = { getListIssues(list, emptyIssues = true) { const data = { - // eslint-disable-next-line import/no-deprecated - ...urlParamsToObject(this.filter.path), + ...queryToObject(this.filter.path, { gatherArrays: true }), page: list.page, }; diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index 38c54bc8c5d..31b78014525 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -10,8 +10,7 @@ export const RECEIVE_BOARD_LISTS_SUCCESS = 'RECEIVE_BOARD_LISTS_SUCCESS'; export const RECEIVE_BOARD_LISTS_FAILURE = 'RECEIVE_BOARD_LISTS_FAILURE'; export const SHOW_PROMOTION_LIST = 'SHOW_PROMOTION_LIST'; export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS'; -export const MOVE_LIST = 'MOVE_LIST'; -export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE'; +export const MOVE_LISTS = 'MOVE_LISTS'; export const TOGGLE_LIST_COLLAPSED = 'TOGGLE_LIST_COLLAPSED'; export const REMOVE_LIST = 'REMOVE_LIST'; export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE'; @@ -19,6 +18,9 @@ export const RESET_ITEMS_FOR_LIST = 'RESET_ITEMS_FOR_LIST'; export const REQUEST_ITEMS_FOR_LIST = 'REQUEST_ITEMS_FOR_LIST'; export const RECEIVE_ITEMS_FOR_LIST_FAILURE = 'RECEIVE_ITEMS_FOR_LIST_FAILURE'; export const RECEIVE_ITEMS_FOR_LIST_SUCCESS = 'RECEIVE_ITEMS_FOR_LIST_SUCCESS'; +export const RECEIVE_MILESTONES_REQUEST = 'RECEIVE_MILESTONES_REQUEST'; +export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS'; +export const RECEIVE_MILESTONES_FAILURE = 'RECEIVE_MILESTONES_FAILURE'; export const UPDATE_BOARD_ITEM = 'UPDATE_BOARD_ITEM'; export const REMOVE_BOARD_ITEM = 'REMOVE_BOARD_ITEM'; export const MUTATE_ISSUE_SUCCESS = 'MUTATE_ISSUE_SUCCESS'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index a32a100fa11..668a3dbaa7e 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -1,7 +1,7 @@ -import { pull, union } from 'lodash'; +import { cloneDeep, pull, union } from 'lodash'; import Vue from 'vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; import { formatIssue } from '../boards_util'; import { issuableTypes } from '../constants'; import * as mutationTypes from './mutation_types'; @@ -103,15 +103,12 @@ export default { Vue.set(state.boardLists, list.id, list); }, - [mutationTypes.MOVE_LIST]: (state, { movedList, listAtNewIndex }) => { - const { boardLists } = state; - Vue.set(boardLists, movedList.id, movedList); - Vue.set(boardLists, listAtNewIndex.id, listAtNewIndex); - }, - - [mutationTypes.UPDATE_LIST_FAILURE]: (state, backupList) => { - state.error = s__('Boards|An error occurred while updating the list. Please try again.'); - Vue.set(state, 'boardLists', backupList); + [mutationTypes.MOVE_LISTS]: (state, movedLists) => { + const updatedBoardList = movedLists.reduce((acc, { listId, position }) => { + acc[listId].position = position; + return acc; + }, cloneDeep(state.boardLists)); + Vue.set(state, 'boardLists', updatedBoardList); }, [mutationTypes.TOGGLE_LIST_COLLAPSED]: (state, { listId, collapsed }) => { @@ -136,6 +133,20 @@ export default { Vue.set(state.listsFlags, listId, { [fetchNext ? 'isLoadingMore' : 'isLoading']: true }); }, + [mutationTypes.RECEIVE_MILESTONES_SUCCESS](state, milestones) { + state.milestones = milestones; + state.milestonesLoading = false; + }, + + [mutationTypes.RECEIVE_MILESTONES_REQUEST](state) { + state.milestonesLoading = true; + }, + + [mutationTypes.RECEIVE_MILESTONES_FAILURE](state) { + state.milestonesLoading = false; + state.error = __('Failed to load milestones.'); + }, + [mutationTypes.RECEIVE_ITEMS_FOR_LIST_SUCCESS]: (state, { listItems, listPageInfo, listId }) => { const { listData, boardItems } = listItems; Vue.set(state, 'boardItems', { ...state.boardItems, ...boardItems }); diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index 7be5ae8b583..264a03ff39d 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -19,6 +19,8 @@ export default () => ({ boardConfig: {}, labelsLoading: false, labels: [], + milestones: [], + milestonesLoading: false, highlightedLists: [], selectedBoardItems: [], groupProjects: [], diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue index 12def6e7eef..03fd600e493 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue @@ -24,6 +24,7 @@ import { ADD_CI_VARIABLE_MODAL_ID, AWS_TIP_DISMISSED_COOKIE_NAME, AWS_TIP_MESSAGE, + CONTAINS_VARIABLE_REFERENCE_MESSAGE, } from '../constants'; import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens'; @@ -33,6 +34,7 @@ export default { tokens: awsTokens, tokenList: awsTokenList, awsTipMessage: AWS_TIP_MESSAGE, + containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE, components: { CiEnvironmentsDropdown, GlAlert, @@ -70,6 +72,7 @@ export default { 'awsTipDeployLink', 'awsTipCommandsLink', 'awsTipLearnLink', + 'containsVariableReferenceLink', 'protectedEnvironmentVariablesLink', 'maskedEnvironmentVariablesLink', ]), @@ -99,6 +102,10 @@ export default { const regex = RegExp(this.maskableRegex); return regex.test(this.variable.secret_value); }, + containsVariableReference() { + const regex = RegExp(/\$/); + return regex.test(this.variable.secret_value); + }, displayMaskedError() { return !this.canMask && this.variable.masked; }, @@ -328,6 +335,22 @@ export default { </div> </gl-alert> </gl-collapse> + <gl-alert + v-if="containsVariableReference" + :title="__('Value may contain a variable reference')" + :dismissible="false" + variant="warning" + data-testid="contains-variable-reference" + > + <gl-sprintf :message="$options.containsVariableReferenceMessage"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + <template #docsLink="{ content }"> + <gl-link :href="containsVariableReferenceLink" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> <template #modal-footer> <gl-button @click="hideModal">{{ __('Cancel') }}</gl-button> <gl-button diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js index 564e1d01242..f4002537f79 100644 --- a/app/assets/javascripts/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci_variable_list/constants.js @@ -24,3 +24,7 @@ export const AWS_ACCESS_KEY_ID = 'AWS_ACCESS_KEY_ID'; export const AWS_DEFAULT_REGION = 'AWS_DEFAULT_REGION'; export const AWS_SECRET_ACCESS_KEY = 'AWS_SECRET_ACCESS_KEY'; export const AWS_TOKEN_CONSTANTS = [AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_SECRET_ACCESS_KEY]; + +export const CONTAINS_VARIABLE_REFERENCE_MESSAGE = __( + 'Variable references indicated by %{codeStart}$%{codeEnd} may be expanded. If this is not what you want, consider %{docsLinkStart}using a workaround to prevent expansion%{docsLinkEnd}.', +); diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci_variable_list/index.js index 50856ca9533..7c40f8134d4 100644 --- a/app/assets/javascripts/ci_variable_list/index.js +++ b/app/assets/javascripts/ci_variable_list/index.js @@ -14,6 +14,7 @@ const mountCiVariableListApp = (containerEl) => { awsTipDeployLink, awsTipCommandsLink, awsTipLearnLink, + containsVariableReferenceLink, protectedEnvironmentVariablesLink, maskedEnvironmentVariablesLink, } = containerEl.dataset; @@ -30,6 +31,7 @@ const mountCiVariableListApp = (containerEl) => { awsTipDeployLink, awsTipCommandsLink, awsTipLearnLink, + containsVariableReferenceLink, protectedEnvironmentVariablesLink, maskedEnvironmentVariablesLink, }); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index 42d46dc3d5d..b92f3d5a97b 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -2,6 +2,7 @@ import { GlButton, GlEmptyState, GlLoadingIcon, GlModal, GlLink } from '@gitlab/ui'; import { getParameterByName } from '~/lib/utils/url_utility'; import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue'; +import { PipelineKeyOptions } from '~/pipelines/constants'; import eventHub from '~/pipelines/event_hub'; import PipelinesMixin from '~/pipelines/mixins/pipelines_mixin'; import PipelinesService from '~/pipelines/services/pipelines_service'; @@ -10,6 +11,7 @@ import TablePagination from '~/vue_shared/components/pagination/table_pagination import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { + PipelineKeyOptions, components: { GlButton, GlEmptyState, @@ -205,6 +207,7 @@ export default { :pipelines="state.pipelines" :update-graph-dropdown="updateGraphDropdown" :view-type="viewType" + :pipeline-key-option="$options.PipelineKeyOptions[0]" > <template #table-header-actions> <div v-if="canRenderPipelineButton" class="gl-text-right"> diff --git a/app/assets/javascripts/commons/vue.js b/app/assets/javascripts/commons/vue.js index 23647d99656..cd24a503631 100644 --- a/app/assets/javascripts/commons/vue.js +++ b/app/assets/javascripts/commons/vue.js @@ -1,10 +1,12 @@ import Vue from 'vue'; import GlFeatureFlagsPlugin from '~/vue_shared/gl_feature_flags_plugin'; +import Translate from '~/vue_shared/translate'; if (process.env.NODE_ENV !== 'production') { Vue.config.productionTip = false; } Vue.use(GlFeatureFlagsPlugin); +Vue.use(Translate); Vue.config.ignoredElements = ['gl-emoji']; diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 9a51def7075..a372233e543 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -1,45 +1,111 @@ <script> -import { GlAlert } from '@gitlab/ui'; +import { GlLoadingIcon } from '@gitlab/ui'; import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2'; -import { ContentEditor } from '../services/content_editor'; +import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants'; +import { createContentEditor } from '../services/create_content_editor'; +import ContentEditorError from './content_editor_error.vue'; +import ContentEditorProvider from './content_editor_provider.vue'; +import EditorStateObserver from './editor_state_observer.vue'; +import FormattingBubbleMenu from './formatting_bubble_menu.vue'; import TopToolbar from './top_toolbar.vue'; export default { components: { - GlAlert, + GlLoadingIcon, + ContentEditorError, + ContentEditorProvider, TiptapEditorContent, TopToolbar, + FormattingBubbleMenu, + EditorStateObserver, }, props: { - contentEditor: { - type: ContentEditor, + renderMarkdown: { + type: Function, required: true, }, + uploadsPath: { + type: String, + required: true, + }, + extensions: { + type: Array, + required: false, + default: () => [], + }, + serializerConfig: { + type: Object, + required: false, + default: () => {}, + }, }, data() { return { - error: '', + isLoadingContent: false, + focused: false, }; }, - mounted() { - this.contentEditor.tiptapEditor.on('error', (error) => { - this.error = error; + created() { + const { renderMarkdown, uploadsPath, extensions, serializerConfig } = this; + + // This is a non-reactive attribute intentionally since this is a complex object. + this.contentEditor = createContentEditor({ + renderMarkdown, + uploadsPath, + extensions, + serializerConfig, }); + + this.contentEditor.on(LOADING_CONTENT_EVENT, this.displayLoadingIndicator); + this.contentEditor.on(LOADING_SUCCESS_EVENT, this.hideLoadingIndicator); + this.contentEditor.on(LOADING_ERROR_EVENT, this.hideLoadingIndicator); + this.$emit('initialized', this.contentEditor); + }, + beforeDestroy() { + this.contentEditor.dispose(); + this.contentEditor.off(LOADING_CONTENT_EVENT, this.displayLoadingIndicator); + this.contentEditor.off(LOADING_SUCCESS_EVENT, this.hideLoadingIndicator); + this.contentEditor.off(LOADING_ERROR_EVENT, this.hideLoadingIndicator); + }, + methods: { + displayLoadingIndicator() { + this.isLoadingContent = true; + }, + hideLoadingIndicator() { + this.isLoadingContent = false; + }, + focus() { + this.focused = true; + }, + blur() { + this.focused = false; + }, + notifyChange() { + this.$emit('change', { + empty: this.contentEditor.empty, + }); + }, }, }; </script> <template> - <div> - <gl-alert v-if="error" class="gl-mb-6" variant="danger" @dismiss="error = ''"> - {{ error }} - </gl-alert> - <div - data-testid="content-editor" - class="md-area" - :class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }" - > - <top-toolbar ref="toolbar" class="gl-mb-4" :content-editor="contentEditor" /> - <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" /> + <content-editor-provider :content-editor="contentEditor"> + <div> + <editor-state-observer @docUpdate="notifyChange" @focus="focus" @blur="blur" /> + <content-editor-error /> + <div + data-testid="content-editor" + data-qa-selector="content_editor_container" + class="md-area" + :class="{ 'is-focused': focused }" + > + <top-toolbar ref="toolbar" class="gl-mb-4" /> + <formatting-bubble-menu /> + <div v-if="isLoadingContent" class="gl-w-full gl-display-flex gl-justify-content-center"> + <gl-loading-icon size="sm" /> + </div> + <tiptap-editor-content v-else class="md" :editor="contentEditor.tiptapEditor" /> + </div> </div> - </div> + </content-editor-provider> </template> diff --git a/app/assets/javascripts/content_editor/components/content_editor_error.vue b/app/assets/javascripts/content_editor/components/content_editor_error.vue new file mode 100644 index 00000000000..031ea92a7e9 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/content_editor_error.vue @@ -0,0 +1,31 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import EditorStateObserver from './editor_state_observer.vue'; + +export default { + components: { + GlAlert, + EditorStateObserver, + }, + data() { + return { + error: null, + }; + }, + methods: { + displayError({ error }) { + this.error = error; + }, + dismissError() { + this.error = null; + }, + }, +}; +</script> +<template> + <editor-state-observer @error="displayError"> + <gl-alert v-if="error" class="gl-mb-6" variant="danger" @dismiss="dismissError"> + {{ error }} + </gl-alert> + </editor-state-observer> +</template> diff --git a/app/assets/javascripts/content_editor/components/content_editor_provider.vue b/app/assets/javascripts/content_editor/components/content_editor_provider.vue new file mode 100644 index 00000000000..630aff9858f --- /dev/null +++ b/app/assets/javascripts/content_editor/components/content_editor_provider.vue @@ -0,0 +1,24 @@ +<script> +export default { + provide() { + // We can't use this.contentEditor due to bug in vue-apollo when + // provide is called in beforeCreate + // See https://github.com/vuejs/vue-apollo/pull/1153 for details + const { contentEditor } = this.$options.propsData; + + return { + contentEditor, + tiptapEditor: contentEditor.tiptapEditor, + }; + }, + props: { + contentEditor: { + type: Object, + required: true, + }, + }, + render() { + return this.$slots.default; + }, +}; +</script> diff --git a/app/assets/javascripts/content_editor/components/editor_state_observer.vue b/app/assets/javascripts/content_editor/components/editor_state_observer.vue new file mode 100644 index 00000000000..2eeb0719096 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue @@ -0,0 +1,40 @@ +<script> +import { debounce } from 'lodash'; + +export const tiptapToComponentMap = { + update: 'docUpdate', + selectionUpdate: 'selectionUpdate', + transaction: 'transaction', + focus: 'focus', + blur: 'blur', + error: 'error', +}; + +const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName]; + +export default { + inject: ['tiptapEditor'], + created() { + this.disposables = []; + + Object.keys(tiptapToComponentMap).forEach((tiptapEvent) => { + const eventHandler = debounce((params) => this.handleTipTapEvent(tiptapEvent, params), 100); + + this.tiptapEditor?.on(tiptapEvent, eventHandler); + + this.disposables.push(() => this.tiptapEditor?.off(tiptapEvent, eventHandler)); + }); + }, + beforeDestroy() { + this.disposables.forEach((dispose) => dispose()); + }, + methods: { + handleTipTapEvent(tiptapEvent, params) { + this.$emit(getComponentEventName(tiptapEvent), params); + }, + }, + render() { + return this.$slots.default; + }, +}; +</script> diff --git a/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue b/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue new file mode 100644 index 00000000000..6c00480b87e --- /dev/null +++ b/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue @@ -0,0 +1,67 @@ +<script> +import { GlButtonGroup } from '@gitlab/ui'; +import { BubbleMenu } from '@tiptap/vue-2'; +import { BUBBLE_MENU_TRACKING_ACTION } from '../constants'; +import trackUIControl from '../services/track_ui_control'; +import ToolbarButton from './toolbar_button.vue'; + +export default { + components: { + BubbleMenu, + GlButtonGroup, + ToolbarButton, + }, + inject: ['tiptapEditor'], + methods: { + trackToolbarControlExecution({ contentType, value }) { + trackUIControl({ action: BUBBLE_MENU_TRACKING_ACTION, property: contentType, value }); + }, + }, +}; +</script> +<template> + <bubble-menu class="gl-shadow gl-rounded-base" :editor="tiptapEditor"> + <gl-button-group> + <toolbar-button + data-testid="bold" + content-type="bold" + icon-name="bold" + editor-command="toggleBold" + category="primary" + size="medium" + :label="__('Bold text')" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="italic" + content-type="italic" + icon-name="italic" + editor-command="toggleItalic" + category="primary" + size="medium" + :label="__('Italic text')" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="strike" + content-type="strike" + icon-name="strikethrough" + editor-command="toggleStrike" + category="primary" + size="medium" + :label="__('Strikethrough')" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="code" + content-type="code" + icon-name="code" + editor-command="toggleCode" + category="primary" + size="medium" + :label="__('Code')" + @execute="trackToolbarControlExecution" + /> + </gl-button-group> + </bubble-menu> +</template> diff --git a/app/assets/javascripts/content_editor/components/toolbar_button.vue b/app/assets/javascripts/content_editor/components/toolbar_button.vue index 0af12812f3b..cdb877152d4 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_button.vue @@ -1,23 +1,21 @@ <script> import { GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; -import { Editor as TiptapEditor } from '@tiptap/vue-2'; +import EditorStateObserver from './editor_state_observer.vue'; export default { components: { GlButton, + EditorStateObserver, }, directives: { GlTooltip, }, + inject: ['tiptapEditor'], props: { iconName: { type: String, required: true, }, - tiptapEditor: { - type: TiptapEditor, - required: true, - }, contentType: { type: String, required: true, @@ -31,13 +29,31 @@ export default { required: false, default: '', }, - }, - computed: { - isActive() { - return this.tiptapEditor.isActive(this.contentType) && this.tiptapEditor.isFocused; + variant: { + type: String, + required: false, + default: 'default', }, + category: { + type: String, + required: false, + default: 'tertiary', + }, + size: { + type: String, + required: false, + default: 'small', + }, + }, + data() { + return { + isActive: null, + }; }, methods: { + updateActive({ editor }) { + this.isActive = editor.isActive(this.contentType) && editor.isFocused; + }, execute() { const { contentType } = this; @@ -51,15 +67,17 @@ export default { }; </script> <template> - <gl-button - v-gl-tooltip - category="tertiary" - size="small" - class="gl-mx-2" - :class="{ active: isActive }" - :aria-label="label" - :title="label" - :icon="iconName" - @click="execute" - /> + <editor-state-observer @transaction="updateActive"> + <gl-button + v-gl-tooltip + :variant="variant" + :category="category" + :size="size" + :class="{ active: isActive }" + :aria-label="label" + :title="label" + :icon="iconName" + @click="execute" + /> + </editor-state-observer> </template> diff --git a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue b/app/assets/javascripts/content_editor/components/toolbar_image_button.vue index ebeee16dbec..649e23c29aa 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_image_button.vue @@ -8,9 +8,8 @@ import { GlDropdownItem, GlTooltipDirective as GlTooltip, } from '@gitlab/ui'; -import { Editor as TiptapEditor } from '@tiptap/vue-2'; -import { acceptedMimes } from '../extensions/image'; -import { getImageAlt } from '../services/utils'; +import { acceptedMimes } from '../services/upload_helpers'; +import { extractFilename } from '../services/utils'; export default { components: { @@ -24,12 +23,7 @@ export default { directives: { GlTooltip, }, - props: { - tiptapEditor: { - type: TiptapEditor, - required: true, - }, - }, + inject: ['tiptapEditor'], data() { return { imgSrc: '', @@ -47,7 +41,7 @@ export default { .setImage({ src: this.imgSrc, canonicalSrc: this.imgSrc, - alt: getImageAlt(this.imgSrc), + alt: extractFilename(this.imgSrc), }) .run(); @@ -64,7 +58,7 @@ export default { this.tiptapEditor .chain() .focus() - .uploadImage({ + .uploadAttachment({ file: e.target.files[0], }) .run(); @@ -73,7 +67,7 @@ export default { this.emitExecute('upload'); }, }, - acceptedMimes, + acceptedMimes: acceptedMimes.image, }; </script> <template> @@ -104,6 +98,7 @@ export default { name="content_editor_image" :accept="$options.acceptedMimes" class="gl-display-none" + data-qa-selector="file_upload_field" @change="onFileSelect" /> </gl-dropdown> diff --git a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue index 8f57959a73f..ff525e52873 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue @@ -8,10 +8,9 @@ import { GlDropdownItem, GlTooltipDirective as GlTooltip, } from '@gitlab/ui'; -import { Editor as TiptapEditor } from '@tiptap/vue-2'; +import Link from '../extensions/link'; import { hasSelection } from '../services/utils'; - -export const linkContentType = 'link'; +import EditorStateObserver from './editor_state_observer.vue'; export default { components: { @@ -21,34 +20,32 @@ export default { GlDropdownDivider, GlDropdownItem, GlButton, + EditorStateObserver, }, directives: { GlTooltip, }, - props: { - tiptapEditor: { - type: TiptapEditor, - required: true, - }, - }, + inject: ['tiptapEditor'], data() { return { linkHref: '', + isActive: false, }; }, - computed: { - isActive() { - return this.tiptapEditor.isActive(linkContentType); + methods: { + resetFields() { + this.imgSrc = ''; + this.$refs.fileSelector.value = ''; }, - }, - mounted() { - this.tiptapEditor.on('selectionUpdate', ({ editor }) => { - const { canonicalSrc, href } = editor.getAttributes(linkContentType); + openFileUpload() { + this.$refs.fileSelector.click(); + }, + updateLinkState({ editor }) { + const { canonicalSrc, href } = editor.getAttributes(Link.name); + this.isActive = editor.isActive(Link.name); this.linkHref = canonicalSrc || href; - }); - }, - methods: { + }, updateLink() { this.tiptapEditor .chain() @@ -60,45 +57,70 @@ export default { }) .run(); - this.$emit('execute', { contentType: linkContentType }); + this.$emit('execute', { contentType: Link.name }); }, selectLink() { const { tiptapEditor } = this; // a selection has already been made by the user, so do nothing if (!hasSelection(tiptapEditor)) { - tiptapEditor.chain().focus().extendMarkRange(linkContentType).run(); + tiptapEditor.chain().focus().extendMarkRange(Link.name).run(); } }, removeLink() { this.tiptapEditor.chain().focus().unsetLink().run(); - this.$emit('execute', { contentType: linkContentType }); + this.$emit('execute', { contentType: Link.name }); + }, + onFileSelect(e) { + this.tiptapEditor + .chain() + .focus() + .uploadAttachment({ + file: e.target.files[0], + }) + .run(); + + this.resetFields(); + this.$emit('execute', { contentType: Link.name }); }, }, }; </script> <template> - <gl-dropdown - v-gl-tooltip - :aria-label="__('Insert link')" - :title="__('Insert link')" - :toggle-class="{ active: isActive }" - size="small" - category="tertiary" - icon="link" - @show="selectLink()" - > - <gl-dropdown-form class="gl-px-3!"> - <gl-form-input-group v-model="linkHref" :placeholder="__('Link URL')"> - <template #append> - <gl-button variant="confirm" @click="updateLink()">{{ __('Apply') }}</gl-button> - </template> - </gl-form-input-group> - </gl-dropdown-form> - <gl-dropdown-divider v-if="isActive" /> - <gl-dropdown-item v-if="isActive" @click="removeLink()"> - {{ __('Remove link') }} - </gl-dropdown-item> - </gl-dropdown> + <editor-state-observer @transaction="updateLinkState"> + <gl-dropdown + v-gl-tooltip + :aria-label="__('Insert link')" + :title="__('Insert link')" + :toggle-class="{ active: isActive }" + size="small" + category="tertiary" + icon="link" + @show="selectLink()" + > + <gl-dropdown-form class="gl-px-3!"> + <gl-form-input-group v-model="linkHref" :placeholder="__('Link URL')"> + <template #append> + <gl-button variant="confirm" @click="updateLink">{{ __('Apply') }}</gl-button> + </template> + </gl-form-input-group> + </gl-dropdown-form> + <gl-dropdown-divider /> + <gl-dropdown-item v-if="isActive" @click="removeLink"> + {{ __('Remove link') }} + </gl-dropdown-item> + <gl-dropdown-item v-else @click="openFileUpload"> + {{ __('Upload file') }} + </gl-dropdown-item> + + <input + ref="fileSelector" + type="file" + name="content_editor_attachment" + class="gl-display-none" + @change="onFileSelect" + /> + </gl-dropdown> + </editor-state-observer> </template> diff --git a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue index 49d3006e9bf..46db806da94 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue @@ -1,29 +1,23 @@ <script> import { GlDropdown, GlDropdownDivider, GlDropdownForm, GlButton } from '@gitlab/ui'; -import { Editor as TiptapEditor } from '@tiptap/vue-2'; import { __, sprintf } from '~/locale'; import { clamp } from '../services/utils'; export const tableContentType = 'table'; -const MIN_ROWS = 3; -const MIN_COLS = 3; -const MAX_ROWS = 8; -const MAX_COLS = 8; +const MIN_ROWS = 5; +const MIN_COLS = 5; +const MAX_ROWS = 10; +const MAX_COLS = 10; export default { components: { + GlButton, GlDropdown, GlDropdownDivider, GlDropdownForm, - GlButton, - }, - props: { - tiptapEditor: { - type: TiptapEditor, - required: true, - }, }, + inject: ['tiptapEditor'], data() { return { maxRows: MIN_ROWS, @@ -68,22 +62,22 @@ export default { }; </script> <template> - <gl-dropdown size="small" category="tertiary" icon="table"> - <gl-dropdown-form class="gl-px-3! gl-w-auto!"> - <div class="gl-w-auto!"> - <div v-for="r of list(maxRows)" :key="r" class="gl-display-flex"> - <gl-button - v-for="c of list(maxCols)" - :key="c" - :data-testid="`table-${r}-${c}`" - :class="{ 'gl-bg-blue-50!': r <= rows && c <= cols }" - :aria-label="getButtonLabel(r, c)" - class="gl-display-inline! gl-px-0! gl-w-5! gl-h-5! gl-rounded-0!" - @mouseover="setRowsAndCols(r, c)" - @click="insertTable()" - /> - </div> - <gl-dropdown-divider /> + <gl-dropdown size="small" category="tertiary" icon="table" class="table-dropdown"> + <gl-dropdown-form class="gl-px-3!"> + <div v-for="r of list(maxRows)" :key="r" class="gl-display-flex"> + <gl-button + v-for="c of list(maxCols)" + :key="c" + :data-testid="`table-${r}-${c}`" + :class="{ 'active gl-bg-blue-50!': r <= rows && c <= cols }" + :aria-label="getButtonLabel(r, c)" + class="table-creator-grid-item gl-display-inline gl-rounded-0! gl-w-6! gl-h-6! gl-p-0!" + @mouseover="setRowsAndCols(r, c)" + @click="insertTable()" + /> + </div> + <gl-dropdown-divider class="gl-my-3! gl-mx-n3!" /> + <div class="gl-px-1"> {{ getButtonLabel(rows, cols) }} </div> </gl-dropdown-form> diff --git a/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue index 473fc472c1b..13728d4001d 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue @@ -1,29 +1,25 @@ <script> import { GlDropdown, GlDropdownItem, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; -import { Editor as TiptapEditor } from '@tiptap/vue-2'; import { __ } from '~/locale'; import { TEXT_STYLE_DROPDOWN_ITEMS } from '../constants'; +import EditorStateObserver from './editor_state_observer.vue'; export default { components: { GlDropdown, GlDropdownItem, + EditorStateObserver, }, directives: { GlTooltip, }, - props: { - tiptapEditor: { - type: TiptapEditor, - required: true, - }, + inject: ['tiptapEditor'], + data() { + return { + activeItem: null, + }; }, computed: { - activeItem() { - return TEXT_STYLE_DROPDOWN_ITEMS.find((item) => - this.tiptapEditor.isActive(item.contentType, item.commandParams), - ); - }, activeItemLabel() { const { activeItem } = this; @@ -31,6 +27,11 @@ export default { }, }, methods: { + updateActiveItem({ editor }) { + this.activeItem = TEXT_STYLE_DROPDOWN_ITEMS.find((item) => + editor.isActive(item.contentType, item.commandParams), + ); + }, execute(item) { const { editorCommand, contentType, commandParams } = item; const value = commandParams?.level; @@ -38,8 +39,8 @@ export default { if (editorCommand) { this.tiptapEditor .chain() - .focus() [editorCommand](commandParams || {}) + .focus() .run(); } @@ -56,20 +57,25 @@ export default { }; </script> <template> - <gl-dropdown - v-gl-tooltip="$options.i18n.placeholder" - size="small" - :disabled="!activeItem" - :text="activeItemLabel" - > - <gl-dropdown-item - v-for="(item, index) in $options.items" - :key="index" - is-check-item - :is-checked="isActive(item)" - @click="execute(item)" + <editor-state-observer @transaction="updateActiveItem"> + <gl-dropdown + v-gl-tooltip="$options.i18n.placeholder" + size="small" + data-qa-selector="text_style_dropdown" + :disabled="!activeItem" + :text="activeItemLabel" > - {{ item.label }} - </gl-dropdown-item> - </gl-dropdown> + <gl-dropdown-item + v-for="(item, index) in $options.items" + :key="index" + is-check-item + :is-checked="isActive(item)" + data-qa-selector="text_style_menu_item" + :data-qa-text-style="item.label" + @click="execute(item)" + > + {{ item.label }} + </gl-dropdown-item> + </gl-dropdown> + </editor-state-observer> </template> diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue index fafc7a660e7..82a449ae6af 100644 --- a/app/assets/javascripts/content_editor/components/top_toolbar.vue +++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue @@ -1,7 +1,5 @@ <script> -import Tracking from '~/tracking'; -import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from '../constants'; -import { ContentEditor } from '../services/content_editor'; +import trackUIControl from '../services/track_ui_control'; import Divider from './divider.vue'; import ToolbarButton from './toolbar_button.vue'; import ToolbarImageButton from './toolbar_image_button.vue'; @@ -9,10 +7,6 @@ import ToolbarLinkButton from './toolbar_link_button.vue'; import ToolbarTableButton from './toolbar_table_button.vue'; import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue'; -const trackingMixin = Tracking.mixin({ - label: CONTENT_EDITOR_TRACKING_LABEL, -}); - export default { components: { ToolbarButton, @@ -22,19 +16,9 @@ export default { ToolbarImageButton, Divider, }, - mixins: [trackingMixin], - props: { - contentEditor: { - type: ContentEditor, - required: true, - }, - }, methods: { - trackToolbarControlExecution({ contentType: property, value }) { - this.track(TOOLBAR_CONTROL_TRACKING_ACTION, { - property, - value, - }); + trackToolbarControlExecution({ contentType, value }) { + trackUIControl({ property: contentType, value }); }, }, }; @@ -45,7 +29,6 @@ export default { > <toolbar-text-style-dropdown data-testid="text-styles" - :tiptap-editor="contentEditor.tiptapEditor" @execute="trackToolbarControlExecution" /> <divider /> @@ -53,99 +36,91 @@ export default { data-testid="bold" content-type="bold" icon-name="bold" + class="gl-mx-2" editor-command="toggleBold" :label="__('Bold text')" - :tiptap-editor="contentEditor.tiptapEditor" @execute="trackToolbarControlExecution" /> <toolbar-button data-testid="italic" content-type="italic" icon-name="italic" + class="gl-mx-2" editor-command="toggleItalic" :label="__('Italic text')" - :tiptap-editor="contentEditor.tiptapEditor" @execute="trackToolbarControlExecution" /> <toolbar-button data-testid="strike" content-type="strike" icon-name="strikethrough" + class="gl-mx-2" editor-command="toggleStrike" :label="__('Strikethrough')" - :tiptap-editor="contentEditor.tiptapEditor" @execute="trackToolbarControlExecution" /> <toolbar-button data-testid="code" content-type="code" icon-name="code" + class="gl-mx-2" editor-command="toggleCode" :label="__('Code')" - :tiptap-editor="contentEditor.tiptapEditor" - @execute="trackToolbarControlExecution" - /> - <toolbar-link-button - data-testid="link" - :tiptap-editor="contentEditor.tiptapEditor" @execute="trackToolbarControlExecution" /> + <toolbar-link-button data-testid="link" @execute="trackToolbarControlExecution" /> <divider /> <toolbar-image-button ref="imageButton" data-testid="image" - :tiptap-editor="contentEditor.tiptapEditor" @execute="trackToolbarControlExecution" /> <toolbar-button data-testid="blockquote" content-type="blockquote" icon-name="quote" + class="gl-mx-2" editor-command="toggleBlockquote" :label="__('Insert a quote')" - :tiptap-editor="contentEditor.tiptapEditor" @execute="trackToolbarControlExecution" /> <toolbar-button data-testid="code-block" content-type="codeBlock" icon-name="doc-code" + class="gl-mx-2" editor-command="toggleCodeBlock" :label="__('Insert a code block')" - :tiptap-editor="contentEditor.tiptapEditor" @execute="trackToolbarControlExecution" /> <toolbar-button data-testid="bullet-list" content-type="bulletList" icon-name="list-bulleted" + class="gl-mx-2" editor-command="toggleBulletList" :label="__('Add a bullet list')" - :tiptap-editor="contentEditor.tiptapEditor" @execute="trackToolbarControlExecution" /> <toolbar-button data-testid="ordered-list" content-type="orderedList" icon-name="list-numbered" + class="gl-mx-2" editor-command="toggleOrderedList" :label="__('Add a numbered list')" - :tiptap-editor="contentEditor.tiptapEditor" @execute="trackToolbarControlExecution" /> <toolbar-button data-testid="horizontal-rule" content-type="horizontalRule" icon-name="dash" + class="gl-mx-2" editor-command="setHorizontalRule" :label="__('Add a horizontal rule')" - :tiptap-editor="contentEditor.tiptapEditor" - @execute="trackToolbarControlExecution" - /> - <toolbar-table-button - :tiptap-editor="contentEditor.tiptapEditor" @execute="trackToolbarControlExecution" /> + <toolbar-table-button @execute="trackToolbarControlExecution" /> </div> </template> <style> diff --git a/app/assets/javascripts/content_editor/constants.js b/app/assets/javascripts/content_editor/constants.js index 7a5f1d3ed1f..f277508f628 100644 --- a/app/assets/javascripts/content_editor/constants.js +++ b/app/assets/javascripts/content_editor/constants.js @@ -6,6 +6,7 @@ export const PROVIDE_SERIALIZER_OR_RENDERER_ERROR = s__( export const CONTENT_EDITOR_TRACKING_LABEL = 'content_editor'; export const TOOLBAR_CONTROL_TRACKING_ACTION = 'execute_toolbar_control'; +export const BUBBLE_MENU_TRACKING_ACTION = 'execute_bubble_menu_control'; export const KEYBOARD_SHORTCUT_TRACKING_ACTION = 'execute_keyboard_shortcut'; export const INPUT_RULE_TRACKING_ACTION = 'execute_input_rule'; @@ -40,3 +41,7 @@ export const TEXT_STYLE_DROPDOWN_ITEMS = [ label: __('Normal text'), }, ]; + +export const LOADING_CONTENT_EVENT = 'loadingContent'; +export const LOADING_SUCCESS_EVENT = 'loadingSuccess'; +export const LOADING_ERROR_EVENT = 'loadingError'; diff --git a/app/assets/javascripts/content_editor/extensions/attachment.js b/app/assets/javascripts/content_editor/extensions/attachment.js new file mode 100644 index 00000000000..29ee282f2d2 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/attachment.js @@ -0,0 +1,53 @@ +import { Extension } from '@tiptap/core'; +import { Plugin, PluginKey } from 'prosemirror-state'; +import { handleFileEvent } from '../services/upload_helpers'; + +export default Extension.create({ + name: 'attachment', + + defaultOptions: { + uploadsPath: null, + renderMarkdown: null, + }, + + addCommands() { + return { + uploadAttachment: ({ file }) => () => { + const { uploadsPath, renderMarkdown } = this.options; + + return handleFileEvent({ file, uploadsPath, renderMarkdown, editor: this.editor }); + }, + }; + }, + addProseMirrorPlugins() { + const { editor } = this; + + return [ + new Plugin({ + key: new PluginKey('attachment'), + props: { + handlePaste: (_, event) => { + const { uploadsPath, renderMarkdown } = this.options; + + return handleFileEvent({ + editor, + file: event.clipboardData.files[0], + uploadsPath, + renderMarkdown, + }); + }, + handleDrop: (_, event) => { + const { uploadsPath, renderMarkdown } = this.options; + + return handleFileEvent({ + editor, + file: event.dataTransfer.files[0], + uploadsPath, + renderMarkdown, + }); + }, + }, + }), + ]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/blockquote.js b/app/assets/javascripts/content_editor/extensions/blockquote.js index a4297b4550c..45f53fe230b 100644 --- a/app/assets/javascripts/content_editor/extensions/blockquote.js +++ b/app/assets/javascripts/content_editor/extensions/blockquote.js @@ -1,5 +1 @@ -import { Blockquote } from '@tiptap/extension-blockquote'; -import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; - -export const tiptapExtension = Blockquote; -export const serializer = defaultMarkdownSerializer.nodes.blockquote; +export { Blockquote as default } from '@tiptap/extension-blockquote'; diff --git a/app/assets/javascripts/content_editor/extensions/bold.js b/app/assets/javascripts/content_editor/extensions/bold.js index e90e7b59da0..0b7b22265b6 100644 --- a/app/assets/javascripts/content_editor/extensions/bold.js +++ b/app/assets/javascripts/content_editor/extensions/bold.js @@ -1,5 +1 @@ -import { Bold } from '@tiptap/extension-bold'; -import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; - -export const tiptapExtension = Bold; -export const serializer = defaultMarkdownSerializer.marks.strong; +export { Bold as default } from '@tiptap/extension-bold'; diff --git a/app/assets/javascripts/content_editor/extensions/bullet_list.js b/app/assets/javascripts/content_editor/extensions/bullet_list.js index 178b798e2d4..01ead571fe1 100644 --- a/app/assets/javascripts/content_editor/extensions/bullet_list.js +++ b/app/assets/javascripts/content_editor/extensions/bullet_list.js @@ -1,5 +1 @@ -import { BulletList } from '@tiptap/extension-bullet-list'; -import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; - -export const tiptapExtension = BulletList; -export const serializer = defaultMarkdownSerializer.nodes.bullet_list; +export { BulletList as default } from '@tiptap/extension-bullet-list'; diff --git a/app/assets/javascripts/content_editor/extensions/code.js b/app/assets/javascripts/content_editor/extensions/code.js index 8be50dc39c5..f93c22ad10e 100644 --- a/app/assets/javascripts/content_editor/extensions/code.js +++ b/app/assets/javascripts/content_editor/extensions/code.js @@ -1,5 +1 @@ -import { Code } from '@tiptap/extension-code'; -import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; - -export const tiptapExtension = Code; -export const serializer = defaultMarkdownSerializer.marks.code; +export { Code as default } from '@tiptap/extension-code'; diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js index 50d72f4089a..c6d32fb8547 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -1,10 +1,9 @@ import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'; import * as lowlight from 'lowlight'; -import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; const extractLanguage = (element) => element.getAttribute('lang'); -const ExtendedCodeBlockLowlight = CodeBlockLowlight.extend({ +export default CodeBlockLowlight.extend({ addAttributes() { return { language: { @@ -15,18 +14,6 @@ const ExtendedCodeBlockLowlight = CodeBlockLowlight.extend({ }; }, }, - /* `params` is the name of the attribute that - prosemirror-markdown uses to extract the language - of a codeblock. - https://github.com/ProseMirror/prosemirror-markdown/blob/master/src/to_markdown.js#L62 - */ - params: { - parseHTML: (element) => { - return { - params: extractLanguage(element), - }; - }, - }, class: { default: 'code highlight js-syntax-highlight', }, @@ -38,6 +25,3 @@ const ExtendedCodeBlockLowlight = CodeBlockLowlight.extend({ }).configure({ lowlight, }); - -export const tiptapExtension = ExtendedCodeBlockLowlight; -export const serializer = defaultMarkdownSerializer.nodes.code_block; diff --git a/app/assets/javascripts/content_editor/extensions/document.js b/app/assets/javascripts/content_editor/extensions/document.js index 99aa8d6235a..27496fd60b7 100644 --- a/app/assets/javascripts/content_editor/extensions/document.js +++ b/app/assets/javascripts/content_editor/extensions/document.js @@ -1,3 +1 @@ -import Document from '@tiptap/extension-document'; - -export const tiptapExtension = Document; +export { Document as default } from '@tiptap/extension-document'; diff --git a/app/assets/javascripts/content_editor/extensions/dropcursor.js b/app/assets/javascripts/content_editor/extensions/dropcursor.js index 44c378ac7db..825dc73b9d9 100644 --- a/app/assets/javascripts/content_editor/extensions/dropcursor.js +++ b/app/assets/javascripts/content_editor/extensions/dropcursor.js @@ -1,3 +1 @@ -import Dropcursor from '@tiptap/extension-dropcursor'; - -export const tiptapExtension = Dropcursor; +export { Dropcursor as default } from '@tiptap/extension-dropcursor'; diff --git a/app/assets/javascripts/content_editor/extensions/emoji.js b/app/assets/javascripts/content_editor/extensions/emoji.js new file mode 100644 index 00000000000..d88b9f92215 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/emoji.js @@ -0,0 +1,93 @@ +import { Node } from '@tiptap/core'; +import { InputRule } from 'prosemirror-inputrules'; +import { initEmojiMap, getAllEmoji } from '~/emoji'; + +export const emojiInputRegex = /(?:^|\s)((?::)((?:\w+))(?::))$/; + +export default Node.create({ + name: 'emoji', + + inline: true, + + group: 'inline', + + draggable: true, + + addAttributes() { + return { + moji: { + default: null, + parseHTML: (element) => { + return { + moji: element.textContent, + }; + }, + }, + name: { + default: null, + parseHTML: (element) => { + return { + name: element.dataset.name, + }; + }, + }, + title: { + default: null, + }, + unicodeVersion: { + default: '6.0', + parseHTML: (element) => { + return { + unicodeVersion: element.dataset.unicodeVersion, + }; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'gl-emoji', + }, + ]; + }, + + renderHTML({ node }) { + return [ + 'gl-emoji', + { + 'data-name': node.attrs.name, + title: node.attrs.title, + 'data-unicode-version': node.attrs.unicodeVersion, + }, + node.attrs.moji, + ]; + }, + + addInputRules() { + return [ + new InputRule(emojiInputRegex, (state, match, start, end) => { + const [, , name] = match; + const emojis = getAllEmoji(); + const emoji = emojis[name]; + const { tr } = state; + + if (emoji) { + tr.replaceWith(start, end, [ + state.schema.text(' '), + this.type.create({ name, moji: emoji.e, unicodeVersion: emoji.u, title: emoji.d }), + ]); + + return tr; + } + + return null; + }), + ]; + }, + + onCreate() { + initEmojiMap(); + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/gapcursor.js b/app/assets/javascripts/content_editor/extensions/gapcursor.js index 2db862e4580..ef88cd92b4e 100644 --- a/app/assets/javascripts/content_editor/extensions/gapcursor.js +++ b/app/assets/javascripts/content_editor/extensions/gapcursor.js @@ -1,3 +1 @@ -import Gapcursor from '@tiptap/extension-gapcursor'; - -export const tiptapExtension = Gapcursor; +export { Gapcursor as default } from '@tiptap/extension-gapcursor'; diff --git a/app/assets/javascripts/content_editor/extensions/hard_break.js b/app/assets/javascripts/content_editor/extensions/hard_break.js index 756eefa875c..fb81c6b79b6 100644 --- a/app/assets/javascripts/content_editor/extensions/hard_break.js +++ b/app/assets/javascripts/content_editor/extensions/hard_break.js @@ -1,13 +1,9 @@ import { HardBreak } from '@tiptap/extension-hard-break'; -import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; -const ExtendedHardBreak = HardBreak.extend({ +export default HardBreak.extend({ addKeyboardShortcuts() { return { 'Shift-Enter': () => this.editor.commands.setHardBreak(), }; }, }); - -export const tiptapExtension = ExtendedHardBreak; -export const serializer = defaultMarkdownSerializer.nodes.hard_break; diff --git a/app/assets/javascripts/content_editor/extensions/heading.js b/app/assets/javascripts/content_editor/extensions/heading.js index f69869d1e09..48303cdeca4 100644 --- a/app/assets/javascripts/content_editor/extensions/heading.js +++ b/app/assets/javascripts/content_editor/extensions/heading.js @@ -1,5 +1 @@ -import { Heading } from '@tiptap/extension-heading'; -import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; - -export const tiptapExtension = Heading; -export const serializer = defaultMarkdownSerializer.nodes.heading; +export { Heading as default } from '@tiptap/extension-heading'; diff --git a/app/assets/javascripts/content_editor/extensions/history.js b/app/assets/javascripts/content_editor/extensions/history.js index 554d797d30a..7c9d92d7b4e 100644 --- a/app/assets/javascripts/content_editor/extensions/history.js +++ b/app/assets/javascripts/content_editor/extensions/history.js @@ -1,3 +1 @@ -import History from '@tiptap/extension-history'; - -export const tiptapExtension = History; +export { History as default } from '@tiptap/extension-history'; diff --git a/app/assets/javascripts/content_editor/extensions/horizontal_rule.js b/app/assets/javascripts/content_editor/extensions/horizontal_rule.js index c287938af5c..c8ec45d835c 100644 --- a/app/assets/javascripts/content_editor/extensions/horizontal_rule.js +++ b/app/assets/javascripts/content_editor/extensions/horizontal_rule.js @@ -1,12 +1,10 @@ import { nodeInputRule } from '@tiptap/core'; import { HorizontalRule } from '@tiptap/extension-horizontal-rule'; -import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; export const hrInputRuleRegExp = /^---$/; -export const tiptapExtension = HorizontalRule.extend({ +export default HorizontalRule.extend({ addInputRules() { return [nodeInputRule(hrInputRuleRegExp, this.type)]; }, }); -export const serializer = defaultMarkdownSerializer.nodes.horizontal_rule; diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js index 4dd8a1376ad..c9e8dfa4ad9 100644 --- a/app/assets/javascripts/content_editor/extensions/image.js +++ b/app/assets/javascripts/content_editor/extensions/image.js @@ -1,58 +1,14 @@ import { Image } from '@tiptap/extension-image'; import { VueNodeViewRenderer } from '@tiptap/vue-2'; -import { Plugin, PluginKey } from 'prosemirror-state'; -import { __ } from '~/locale'; import ImageWrapper from '../components/wrappers/image.vue'; -import { uploadFile } from '../services/upload_file'; -import { getImageAlt, readFileAsDataURL } from '../services/utils'; - -export const acceptedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/jpg']; const resolveImageEl = (element) => element.nodeName === 'IMG' ? element : element.querySelector('img'); -const startFileUpload = async ({ editor, file, uploadsPath, renderMarkdown }) => { - const encodedSrc = await readFileAsDataURL(file); - const { view } = editor; - - editor.commands.setImage({ uploading: true, src: encodedSrc }); - - const { state } = view; - const position = state.selection.from - 1; - const { tr } = state; - - try { - const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown }); - - view.dispatch( - tr.setNodeMarkup(position, undefined, { - uploading: false, - src: encodedSrc, - alt: getImageAlt(src), - canonicalSrc, - }), - ); - } catch (e) { - editor.commands.deleteRange({ from: position, to: position + 1 }); - editor.emit('error', __('An error occurred while uploading the image. Please try again.')); - } -}; - -const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown }) => { - if (acceptedMimes.includes(file?.type)) { - startFileUpload({ editor, file, uploadsPath, renderMarkdown }); - - return true; - } - - return false; -}; - -const ExtendedImage = Image.extend({ +export default Image.extend({ defaultOptions: { ...Image.options, - uploadsPath: null, - renderMarkdown: null, + inline: true, }, addAttributes() { return { @@ -107,62 +63,7 @@ const ExtendedImage = Image.extend({ }, ]; }, - addCommands() { - return { - ...this.parent(), - uploadImage: ({ file }) => () => { - const { uploadsPath, renderMarkdown } = this.options; - - handleFileEvent({ file, uploadsPath, renderMarkdown, editor: this.editor }); - }, - }; - }, - addProseMirrorPlugins() { - const { editor } = this; - - return [ - new Plugin({ - key: new PluginKey('handleDropAndPasteImages'), - props: { - handlePaste: (_, event) => { - const { uploadsPath, renderMarkdown } = this.options; - - return handleFileEvent({ - editor, - file: event.clipboardData.files[0], - uploadsPath, - renderMarkdown, - }); - }, - handleDrop: (_, event) => { - const { uploadsPath, renderMarkdown } = this.options; - - return handleFileEvent({ - editor, - file: event.dataTransfer.files[0], - uploadsPath, - renderMarkdown, - }); - }, - }, - }), - ]; - }, addNodeView() { return VueNodeViewRenderer(ImageWrapper); }, }); - -const serializer = (state, node) => { - const { alt, canonicalSrc, src, title } = node.attrs; - const quotedTitle = title ? ` ${state.quote(title)}` : ''; - - state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`); -}; - -export const configure = ({ renderMarkdown, uploadsPath }) => { - return { - tiptapExtension: ExtendedImage.configure({ inline: true, renderMarkdown, uploadsPath }), - serializer, - }; -}; diff --git a/app/assets/javascripts/content_editor/extensions/inline_diff.js b/app/assets/javascripts/content_editor/extensions/inline_diff.js new file mode 100644 index 00000000000..9471d324764 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/inline_diff.js @@ -0,0 +1,50 @@ +import { Mark, markInputRule, mergeAttributes } from '@tiptap/core'; + +export const inputRegexAddition = /(\{\+(.+?)\+\})$/gm; +export const inputRegexDeletion = /(\{-(.+?)-\})$/gm; + +export default Mark.create({ + name: 'inlineDiff', + + defaultOptions: { + HTMLAttributes: {}, + }, + + addAttributes() { + return { + type: { + default: 'addition', + parseHTML: (element) => { + return { + type: element.classList.contains('deletion') ? 'deletion' : 'addition', + }; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'span.idiff', + }, + ]; + }, + + renderHTML({ HTMLAttributes: { type, ...HTMLAttributes } }) { + return [ + 'span', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + class: `idiff left right ${type}`, + }), + 0, + ]; + }, + + addInputRules() { + return [ + markInputRule(inputRegexAddition, this.type, () => ({ type: 'addition' })), + markInputRule(inputRegexDeletion, this.type, () => ({ type: 'deletion' })), + ]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/italic.js b/app/assets/javascripts/content_editor/extensions/italic.js index b8a7c4aba3e..99e9922044d 100644 --- a/app/assets/javascripts/content_editor/extensions/italic.js +++ b/app/assets/javascripts/content_editor/extensions/italic.js @@ -1,4 +1 @@ -import { Italic } from '@tiptap/extension-italic'; - -export const tiptapExtension = Italic; -export const serializer = { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true }; +export { Italic as default } from '@tiptap/extension-italic'; diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js index 12019ab4636..53104fe07a3 100644 --- a/app/assets/javascripts/content_editor/extensions/link.js +++ b/app/assets/javascripts/content_editor/extensions/link.js @@ -20,7 +20,11 @@ export const extractHrefFromMarkdownLink = (match) => { return extractHrefFromMatch(match); }; -export const tiptapExtension = Link.extend({ +export default Link.extend({ + defaultOptions: { + ...Link.options, + openOnClick: false, + }, addInputRules() { return [ markInputRule(markdownLinkSyntaxInputRuleRegExp, this.type, extractHrefFromMarkdownLink), @@ -48,16 +52,4 @@ export const tiptapExtension = Link.extend({ }, }; }, -}).configure({ - openOnClick: false, }); - -export const serializer = { - open() { - return '['; - }, - close(state, mark) { - const href = mark.attrs.canonicalSrc || mark.attrs.href; - return `](${state.esc(href)}${mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''})`; - }, -}; diff --git a/app/assets/javascripts/content_editor/extensions/list_item.js b/app/assets/javascripts/content_editor/extensions/list_item.js index 86da98f6df7..72454b0905d 100644 --- a/app/assets/javascripts/content_editor/extensions/list_item.js +++ b/app/assets/javascripts/content_editor/extensions/list_item.js @@ -1,5 +1 @@ -import { ListItem } from '@tiptap/extension-list-item'; -import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; - -export const tiptapExtension = ListItem; -export const serializer = defaultMarkdownSerializer.nodes.list_item; +export { ListItem as default } from '@tiptap/extension-list-item'; diff --git a/app/assets/javascripts/content_editor/extensions/loading.js b/app/assets/javascripts/content_editor/extensions/loading.js new file mode 100644 index 00000000000..2324e9b132d --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/loading.js @@ -0,0 +1,24 @@ +import { Node } from '@tiptap/core'; + +export default Node.create({ + name: 'loading', + inline: true, + group: 'inline', + + addAttributes() { + return { + label: { + default: null, + }, + }; + }, + + renderHTML({ node }) { + return [ + 'span', + { class: 'gl-display-inline-flex gl-align-items-center' }, + ['span', { class: 'gl-spinner gl-mx-2' }], + ['span', { class: 'gl-link' }, node.attrs.label], + ]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/ordered_list.js b/app/assets/javascripts/content_editor/extensions/ordered_list.js index d980ab8bf10..9a79187d9c1 100644 --- a/app/assets/javascripts/content_editor/extensions/ordered_list.js +++ b/app/assets/javascripts/content_editor/extensions/ordered_list.js @@ -1,5 +1 @@ -import { OrderedList } from '@tiptap/extension-ordered-list'; -import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; - -export const tiptapExtension = OrderedList; -export const serializer = defaultMarkdownSerializer.nodes.ordered_list; +export { OrderedList as default } from '@tiptap/extension-ordered-list'; diff --git a/app/assets/javascripts/content_editor/extensions/paragraph.js b/app/assets/javascripts/content_editor/extensions/paragraph.js index 6c9f204b8ac..33bf1c94003 100644 --- a/app/assets/javascripts/content_editor/extensions/paragraph.js +++ b/app/assets/javascripts/content_editor/extensions/paragraph.js @@ -1,5 +1 @@ -import { Paragraph } from '@tiptap/extension-paragraph'; -import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; - -export const tiptapExtension = Paragraph; -export const serializer = defaultMarkdownSerializer.nodes.paragraph; +export { Paragraph as default } from '@tiptap/extension-paragraph'; diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js new file mode 100644 index 00000000000..5f4484af9c8 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/reference.js @@ -0,0 +1,78 @@ +import { Node } from '@tiptap/core'; + +export default Node.create({ + name: 'reference', + + inline: true, + + group: 'inline', + + atom: true, + + addAttributes() { + return { + className: { + default: null, + parseHTML: (element) => { + return { + className: element.className, + }; + }, + }, + referenceType: { + default: null, + parseHTML: (element) => { + return { + referenceType: element.dataset.referenceType, + }; + }, + }, + originalText: { + default: null, + parseHTML: (element) => { + return { + originalText: element.dataset.original, + }; + }, + }, + href: { + default: null, + parseHTML: (element) => { + return { + href: element.getAttribute('href'), + }; + }, + }, + text: { + default: null, + parseHTML: (element) => { + return { + text: element.textContent, + }; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'a.gfm:not([data-link=true])', + priority: 51, + }, + ]; + }, + + renderHTML({ node }) { + return [ + 'a', + { + class: node.attrs.className, + href: node.attrs.href, + 'data-reference-type': node.attrs.referenceType, + 'data-original': node.attrs.originalText, + }, + node.attrs.text, + ]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/strike.js b/app/assets/javascripts/content_editor/extensions/strike.js index 6f228e00994..b6c9a968fc2 100644 --- a/app/assets/javascripts/content_editor/extensions/strike.js +++ b/app/assets/javascripts/content_editor/extensions/strike.js @@ -1,9 +1 @@ -import { Strike } from '@tiptap/extension-strike'; - -export const tiptapExtension = Strike; -export const serializer = { - open: '~~', - close: '~~', - mixable: true, - expelEnclosingWhitespace: true, -}; +export { Strike as default } from '@tiptap/extension-strike'; diff --git a/app/assets/javascripts/content_editor/extensions/subscript.js b/app/assets/javascripts/content_editor/extensions/subscript.js new file mode 100644 index 00000000000..4bf89796efe --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/subscript.js @@ -0,0 +1 @@ +export { Subscript as default } from '@tiptap/extension-subscript'; diff --git a/app/assets/javascripts/content_editor/extensions/superscript.js b/app/assets/javascripts/content_editor/extensions/superscript.js new file mode 100644 index 00000000000..3eb7d86d90d --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/superscript.js @@ -0,0 +1 @@ +export { Superscript as default } from '@tiptap/extension-superscript'; diff --git a/app/assets/javascripts/content_editor/extensions/table.js b/app/assets/javascripts/content_editor/extensions/table.js index 566f7a21a85..0f0477cba2e 100644 --- a/app/assets/javascripts/content_editor/extensions/table.js +++ b/app/assets/javascripts/content_editor/extensions/table.js @@ -1,7 +1 @@ -import { Table } from '@tiptap/extension-table'; - -export const tiptapExtension = Table; - -export function serializer(state, node) { - state.renderContent(node); -} +export { Table as default } from '@tiptap/extension-table'; diff --git a/app/assets/javascripts/content_editor/extensions/table_cell.js b/app/assets/javascripts/content_editor/extensions/table_cell.js index 6c25b867466..5bdc39231a1 100644 --- a/app/assets/javascripts/content_editor/extensions/table_cell.js +++ b/app/assets/javascripts/content_editor/extensions/table_cell.js @@ -1,9 +1,5 @@ import { TableCell } from '@tiptap/extension-table-cell'; -export const tiptapExtension = TableCell.extend({ +export default TableCell.extend({ content: 'inline*', }); - -export function serializer(state, node) { - state.renderInline(node); -} diff --git a/app/assets/javascripts/content_editor/extensions/table_header.js b/app/assets/javascripts/content_editor/extensions/table_header.js index 3475857b9e6..23509706e4b 100644 --- a/app/assets/javascripts/content_editor/extensions/table_header.js +++ b/app/assets/javascripts/content_editor/extensions/table_header.js @@ -1,9 +1,5 @@ import { TableHeader } from '@tiptap/extension-table-header'; -export const tiptapExtension = TableHeader.extend({ +export default TableHeader.extend({ content: 'inline*', }); - -export function serializer(state, node) { - state.renderInline(node); -} diff --git a/app/assets/javascripts/content_editor/extensions/table_row.js b/app/assets/javascripts/content_editor/extensions/table_row.js index 07d2eb4faa2..541257a6cbf 100644 --- a/app/assets/javascripts/content_editor/extensions/table_row.js +++ b/app/assets/javascripts/content_editor/extensions/table_row.js @@ -1,51 +1,5 @@ import { TableRow } from '@tiptap/extension-table-row'; -export const tiptapExtension = TableRow.extend({ +export default TableRow.extend({ allowGapCursor: false, }); - -export function serializer(state, node) { - const isHeaderRow = node.child(0).type.name === 'tableHeader'; - - const renderRow = () => { - const cellWidths = []; - - state.flushClose(1); - - state.write('| '); - node.forEach((cell, _, i) => { - if (i) state.write(' | '); - - const { length } = state.out; - state.render(cell, node, i); - cellWidths.push(state.out.length - length); - }); - state.write(' |'); - - state.closeBlock(node); - - return cellWidths; - }; - - const renderHeaderRow = (cellWidths) => { - state.flushClose(1); - - state.write('|'); - node.forEach((cell, _, i) => { - if (i) state.write('|'); - - state.write(cell.attrs.align === 'center' ? ':' : '-'); - state.write(state.repeat('-', cellWidths[i])); - state.write(cell.attrs.align === 'center' || cell.attrs.align === 'right' ? ':' : '-'); - }); - state.write('|'); - - state.closeBlock(node); - }; - - if (isHeaderRow) { - renderHeaderRow(renderRow()); - } else { - renderRow(); - } -} diff --git a/app/assets/javascripts/content_editor/extensions/task_item.js b/app/assets/javascripts/content_editor/extensions/task_item.js new file mode 100644 index 00000000000..6163c0e043b --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/task_item.js @@ -0,0 +1,33 @@ +import { TaskItem } from '@tiptap/extension-task-item'; + +export default TaskItem.extend({ + defaultOptions: { + nested: true, + HTMLAttributes: {}, + }, + + addAttributes() { + return { + checked: { + default: false, + parseHTML: (element) => { + const checkbox = element.querySelector('input[type=checkbox].task-list-item-checkbox'); + return { checked: checkbox?.checked }; + }, + renderHTML: (attributes) => ({ + 'data-checked': attributes.checked, + }), + keepOnSplit: false, + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'li.task-list-item', + priority: 100, + }, + ]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/task_list.js b/app/assets/javascripts/content_editor/extensions/task_list.js new file mode 100644 index 00000000000..b7f6c857bc7 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/task_list.js @@ -0,0 +1,30 @@ +import { mergeAttributes } from '@tiptap/core'; +import { TaskList } from '@tiptap/extension-task-list'; + +export default TaskList.extend({ + addAttributes() { + return { + type: { + default: 'ul', + parseHTML: (element) => { + return { + type: element.tagName.toLowerCase() === 'ol' ? 'ol' : 'ul', + }; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: '.task-list', + priority: 100, + }, + ]; + }, + + renderHTML({ HTMLAttributes: { type, ...HTMLAttributes } }) { + return [type, mergeAttributes(HTMLAttributes, { 'data-type': 'taskList' }), 0]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/text.js b/app/assets/javascripts/content_editor/extensions/text.js index 0d76aa1f1a7..a2865e7010b 100644 --- a/app/assets/javascripts/content_editor/extensions/text.js +++ b/app/assets/javascripts/content_editor/extensions/text.js @@ -1,5 +1 @@ -import { Text } from '@tiptap/extension-text'; -import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; - -export const tiptapExtension = Text; -export const serializer = defaultMarkdownSerializer.nodes.text; +export { Text as default } from '@tiptap/extension-text'; diff --git a/app/assets/javascripts/content_editor/services/build_serializer_config.js b/app/assets/javascripts/content_editor/services/build_serializer_config.js deleted file mode 100644 index 75e2b0f9eba..00000000000 --- a/app/assets/javascripts/content_editor/services/build_serializer_config.js +++ /dev/null @@ -1,22 +0,0 @@ -const buildSerializerConfig = (extensions = []) => - extensions - .filter(({ serializer }) => serializer) - .reduce( - (serializers, { serializer, tiptapExtension: { name, type } }) => { - const collection = `${type}s`; - - return { - ...serializers, - [collection]: { - ...serializers[collection], - [name]: serializer, - }, - }; - }, - { - nodes: {}, - marks: {}, - }, - ); - -export default buildSerializerConfig; diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index 29553f4c2ca..a387322bff7 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -1,8 +1,11 @@ +import eventHubFactory from '~/helpers/event_hub_factory'; +import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants'; /* eslint-disable no-underscore-dangle */ export class ContentEditor { constructor({ tiptapEditor, serializer }) { this._tiptapEditor = tiptapEditor; this._serializer = serializer; + this._eventHub = eventHubFactory(); } get tiptapEditor() { @@ -16,12 +19,45 @@ export class ContentEditor { return doc.childCount === 0 || (doc.childCount === 1 && doc.child(0).childCount === 0); } + dispose() { + this.tiptapEditor.destroy(); + } + + once(type, handler) { + this._eventHub.$once(type, handler); + } + + on(type, handler) { + this._eventHub.$on(type, handler); + } + + emit(type, params = {}) { + this._eventHub.$emit(type, params); + } + + off(type, handler) { + this._eventHub.$off(type, handler); + } + + disposeAllEvents() { + this._eventHub.dispose(); + } + async setSerializedContent(serializedContent) { const { _tiptapEditor: editor, _serializer: serializer } = this; - editor.commands.setContent( - await serializer.deserialize({ schema: editor.schema, content: serializedContent }), - ); + try { + this._eventHub.$emit(LOADING_CONTENT_EVENT); + const document = await serializer.deserialize({ + schema: editor.schema, + content: serializedContent, + }); + editor.commands.setContent(document); + this._eventHub.$emit(LOADING_SUCCESS_EVENT); + } catch (e) { + this._eventHub.$emit(LOADING_ERROR_EVENT, e); + throw e; + } } getSerializedContent() { diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js index 9251fdbbdc5..8997960203a 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -1,38 +1,43 @@ import { Editor } from '@tiptap/vue-2'; import { isFunction } from 'lodash'; import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants'; -import * as Blockquote from '../extensions/blockquote'; -import * as Bold from '../extensions/bold'; -import * as BulletList from '../extensions/bullet_list'; -import * as Code from '../extensions/code'; -import * as CodeBlockHighlight from '../extensions/code_block_highlight'; -import * as Document from '../extensions/document'; -import * as Dropcursor from '../extensions/dropcursor'; -import * as Gapcursor from '../extensions/gapcursor'; -import * as HardBreak from '../extensions/hard_break'; -import * as Heading from '../extensions/heading'; -import * as History from '../extensions/history'; -import * as HorizontalRule from '../extensions/horizontal_rule'; -import * as Image from '../extensions/image'; -import * as Italic from '../extensions/italic'; -import * as Link from '../extensions/link'; -import * as ListItem from '../extensions/list_item'; -import * as OrderedList from '../extensions/ordered_list'; -import * as Paragraph from '../extensions/paragraph'; -import * as Strike from '../extensions/strike'; -import * as Table from '../extensions/table'; -import * as TableCell from '../extensions/table_cell'; -import * as TableHeader from '../extensions/table_header'; -import * as TableRow from '../extensions/table_row'; -import * as Text from '../extensions/text'; -import buildSerializerConfig from './build_serializer_config'; +import Attachment from '../extensions/attachment'; +import Blockquote from '../extensions/blockquote'; +import Bold from '../extensions/bold'; +import BulletList from '../extensions/bullet_list'; +import Code from '../extensions/code'; +import CodeBlockHighlight from '../extensions/code_block_highlight'; +import Document from '../extensions/document'; +import Dropcursor from '../extensions/dropcursor'; +import Emoji from '../extensions/emoji'; +import Gapcursor from '../extensions/gapcursor'; +import HardBreak from '../extensions/hard_break'; +import Heading from '../extensions/heading'; +import History from '../extensions/history'; +import HorizontalRule from '../extensions/horizontal_rule'; +import Image from '../extensions/image'; +import InlineDiff from '../extensions/inline_diff'; +import Italic from '../extensions/italic'; +import Link from '../extensions/link'; +import ListItem from '../extensions/list_item'; +import Loading from '../extensions/loading'; +import OrderedList from '../extensions/ordered_list'; +import Paragraph from '../extensions/paragraph'; +import Reference from '../extensions/reference'; +import Strike from '../extensions/strike'; +import Subscript from '../extensions/subscript'; +import Superscript from '../extensions/superscript'; +import Table from '../extensions/table'; +import TableCell from '../extensions/table_cell'; +import TableHeader from '../extensions/table_header'; +import TableRow from '../extensions/table_row'; +import TaskItem from '../extensions/task_item'; +import TaskList from '../extensions/task_list'; +import Text from '../extensions/text'; import { ContentEditor } from './content_editor'; import createMarkdownSerializer from './markdown_serializer'; import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; -const collectTiptapExtensions = (extensions = []) => - extensions.map(({ tiptapExtension }) => tiptapExtension); - const createTiptapEditor = ({ extensions = [], ...options } = {}) => new Editor({ extensions: [...extensions], @@ -48,6 +53,7 @@ export const createContentEditor = ({ renderMarkdown, uploadsPath, extensions = [], + serializerConfig = { marks: {}, nodes: {} }, tiptapOptions, } = {}) => { if (!isFunction(renderMarkdown)) { @@ -55,6 +61,7 @@ export const createContentEditor = ({ } const builtInContentEditorExtensions = [ + Attachment.configure({ uploadsPath, renderMarkdown }), Blockquote, Bold, BulletList, @@ -62,29 +69,36 @@ export const createContentEditor = ({ CodeBlockHighlight, Document, Dropcursor, + Emoji, Gapcursor, HardBreak, Heading, History, HorizontalRule, - Image.configure({ uploadsPath, renderMarkdown }), + Image, + InlineDiff, Italic, Link, ListItem, + Loading, OrderedList, Paragraph, + Reference, Strike, + Subscript, + Superscript, TableCell, TableHeader, TableRow, Table, + TaskItem, + TaskList, Text, ]; const allExtensions = [...builtInContentEditorExtensions, ...extensions]; - const tiptapExtensions = collectTiptapExtensions(allExtensions).map(trackInputRulesAndShortcuts); - const tiptapEditor = createTiptapEditor({ extensions: tiptapExtensions, ...tiptapOptions }); - const serializerConfig = buildSerializerConfig(allExtensions); + const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts); + const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions }); const serializer = createMarkdownSerializer({ render: renderMarkdown, serializerConfig }); return new ContentEditor({ tiptapEditor, serializer }); diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index f121cc9affd..df4d31c3d7f 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -1,5 +1,165 @@ -import { MarkdownSerializer as ProseMirrorMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; +import { + MarkdownSerializer as ProseMirrorMarkdownSerializer, + defaultMarkdownSerializer, +} from 'prosemirror-markdown/src/to_markdown'; import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model'; +import Blockquote from '../extensions/blockquote'; +import Bold from '../extensions/bold'; +import BulletList from '../extensions/bullet_list'; +import Code from '../extensions/code'; +import CodeBlockHighlight from '../extensions/code_block_highlight'; +import Emoji from '../extensions/emoji'; +import HardBreak from '../extensions/hard_break'; +import Heading from '../extensions/heading'; +import HorizontalRule from '../extensions/horizontal_rule'; +import Image from '../extensions/image'; +import InlineDiff from '../extensions/inline_diff'; +import Italic from '../extensions/italic'; +import Link from '../extensions/link'; +import ListItem from '../extensions/list_item'; +import OrderedList from '../extensions/ordered_list'; +import Paragraph from '../extensions/paragraph'; +import Reference from '../extensions/reference'; +import Strike from '../extensions/strike'; +import Subscript from '../extensions/subscript'; +import Superscript from '../extensions/superscript'; +import Table from '../extensions/table'; +import TableCell from '../extensions/table_cell'; +import TableHeader from '../extensions/table_header'; +import TableRow from '../extensions/table_row'; +import TaskItem from '../extensions/task_item'; +import TaskList from '../extensions/task_list'; +import Text from '../extensions/text'; + +const defaultSerializerConfig = { + marks: { + [Bold.name]: defaultMarkdownSerializer.marks.strong, + [Code.name]: defaultMarkdownSerializer.marks.code, + [Italic.name]: { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true }, + [Subscript.name]: { open: '<sub>', close: '</sub>', mixable: true }, + [Superscript.name]: { open: '<sup>', close: '</sup>', mixable: true }, + [InlineDiff.name]: { + mixable: true, + open(state, mark) { + return mark.attrs.type === 'addition' ? '{+' : '{-'; + }, + close(state, mark) { + return mark.attrs.type === 'addition' ? '+}' : '-}'; + }, + }, + [Link.name]: { + open() { + return '['; + }, + close(state, mark) { + const href = mark.attrs.canonicalSrc || mark.attrs.href; + return `](${state.esc(href)}${ + mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : '' + })`; + }, + }, + [Strike.name]: { + open: '~~', + close: '~~', + mixable: true, + expelEnclosingWhitespace: true, + }, + }, + nodes: { + [Blockquote.name]: defaultMarkdownSerializer.nodes.blockquote, + [BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list, + [CodeBlockHighlight.name]: (state, node) => { + state.write(`\`\`\`${node.attrs.language || ''}\n`); + state.text(node.textContent, false); + state.ensureNewLine(); + state.write('```'); + state.closeBlock(node); + }, + [Emoji.name]: (state, node) => { + const { name } = node.attrs; + + state.write(`:${name}:`); + }, + [HardBreak.name]: defaultMarkdownSerializer.nodes.hard_break, + [Heading.name]: defaultMarkdownSerializer.nodes.heading, + [HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule, + [Image.name]: (state, node) => { + const { alt, canonicalSrc, src, title } = node.attrs; + const quotedTitle = title ? ` ${state.quote(title)}` : ''; + + state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`); + }, + [ListItem.name]: defaultMarkdownSerializer.nodes.list_item, + [OrderedList.name]: defaultMarkdownSerializer.nodes.ordered_list, + [Paragraph.name]: defaultMarkdownSerializer.nodes.paragraph, + [Reference.name]: (state, node) => { + state.write(node.attrs.originalText || node.attrs.text); + }, + [Table.name]: (state, node) => { + state.renderContent(node); + }, + [TableCell.name]: (state, node) => { + state.renderInline(node); + }, + [TableHeader.name]: (state, node) => { + state.renderInline(node); + }, + [TableRow.name]: (state, node) => { + const isHeaderRow = node.child(0).type.name === 'tableHeader'; + + const renderRow = () => { + const cellWidths = []; + + state.flushClose(1); + + state.write('| '); + node.forEach((cell, _, i) => { + if (i) state.write(' | '); + + const { length } = state.out; + state.render(cell, node, i); + cellWidths.push(state.out.length - length); + }); + state.write(' |'); + + state.closeBlock(node); + + return cellWidths; + }; + + const renderHeaderRow = (cellWidths) => { + state.flushClose(1); + + state.write('|'); + node.forEach((cell, _, i) => { + if (i) state.write('|'); + + state.write(cell.attrs.align === 'center' ? ':' : '-'); + state.write(state.repeat('-', cellWidths[i])); + state.write(cell.attrs.align === 'center' || cell.attrs.align === 'right' ? ':' : '-'); + }); + state.write('|'); + + state.closeBlock(node); + }; + + if (isHeaderRow) { + renderHeaderRow(renderRow()); + } else { + renderRow(); + } + }, + [TaskItem.name]: (state, node) => { + state.write(`[${node.attrs.checked ? 'x' : ' '}] `); + state.renderContent(node); + }, + [TaskList.name]: (state, node) => { + if (node.attrs.type === 'ul') defaultMarkdownSerializer.nodes.bullet_list(state, node); + else defaultMarkdownSerializer.nodes.ordered_list(state, node); + }, + [Text.name]: defaultMarkdownSerializer.nodes.text, + }, +}; const wrapHtmlPayload = (payload) => `<div>${payload}</div>`; @@ -50,8 +210,16 @@ export default ({ render = () => null, serializerConfig }) => ({ */ serialize: ({ schema, content }) => { const proseMirrorDocument = schema.nodeFromJSON(content); - const { nodes, marks } = serializerConfig; - const serializer = new ProseMirrorMarkdownSerializer(nodes, marks); + const serializer = new ProseMirrorMarkdownSerializer( + { + ...defaultSerializerConfig.nodes, + ...serializerConfig.nodes, + }, + { + ...defaultSerializerConfig.marks, + ...serializerConfig.marks, + }, + ); return serializer.serialize(proseMirrorDocument, { tightLists: true, diff --git a/app/assets/javascripts/content_editor/services/track_ui_control.js b/app/assets/javascripts/content_editor/services/track_ui_control.js new file mode 100644 index 00000000000..61f130ea861 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/track_ui_control.js @@ -0,0 +1,9 @@ +import Tracking from '~/tracking'; +import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from '../constants'; + +export default ({ action = TOOLBAR_CONTROL_TRACKING_ACTION, property, value } = {}) => + Tracking.event(undefined, action, { + label: CONTENT_EDITOR_TRACKING_LABEL, + property, + value, + }); diff --git a/app/assets/javascripts/content_editor/services/upload_file.js b/app/assets/javascripts/content_editor/services/upload_file.js deleted file mode 100644 index 613c53144a1..00000000000 --- a/app/assets/javascripts/content_editor/services/upload_file.js +++ /dev/null @@ -1,44 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; - -const extractAttachmentLinkUrl = (html) => { - const parser = new DOMParser(); - const { body } = parser.parseFromString(html, 'text/html'); - const link = body.querySelector('a'); - const src = link.getAttribute('href'); - const { canonicalSrc } = link.dataset; - - return { src, canonicalSrc }; -}; - -/** - * Uploads a file with a post request to the URL indicated - * in the uploadsPath parameter. The expected response of the - * uploads service is a JSON object that contains, at least, a - * link property. The link property should contain markdown link - * definition (i.e. [GitLab](https://gitlab.com)). - * - * This Markdown will be rendered to extract its canonical and full - * URLs using GitLab Flavored Markdown renderer in the backend. - * - * @param {Object} params - * @param {String} params.uploadsPath An absolute URL that points to a service - * that allows sending a file for uploading via POST request. - * @param {String} params.renderMarkdown A function that accepts a markdown string - * and returns a rendered version in HTML format. - * @param {File} params.file The file to upload - * - * @returns Returns an object with two properties: - * - * canonicalSrc: The URL as defined in the Markdown - * src: The absolute URL that points to the resource in the server - */ -export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => { - const formData = new FormData(); - formData.append('file', file, file.name); - - const { data } = await axios.post(uploadsPath, formData); - const { markdown } = data.link; - const rendered = await renderMarkdown(markdown); - - return extractAttachmentLinkUrl(rendered); -}; diff --git a/app/assets/javascripts/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js new file mode 100644 index 00000000000..8ac3f719309 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/upload_helpers.js @@ -0,0 +1,123 @@ +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import { extractFilename, readFileAsDataURL } from './utils'; + +export const acceptedMimes = { + image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'], +}; + +const extractAttachmentLinkUrl = (html) => { + const parser = new DOMParser(); + const { body } = parser.parseFromString(html, 'text/html'); + const link = body.querySelector('a'); + const src = link.getAttribute('href'); + const { canonicalSrc } = link.dataset; + + return { src, canonicalSrc }; +}; + +/** + * Uploads a file with a post request to the URL indicated + * in the uploadsPath parameter. The expected response of the + * uploads service is a JSON object that contains, at least, a + * link property. The link property should contain markdown link + * definition (i.e. [GitLab](https://gitlab.com)). + * + * This Markdown will be rendered to extract its canonical and full + * URLs using GitLab Flavored Markdown renderer in the backend. + * + * @param {Object} params + * @param {String} params.uploadsPath An absolute URL that points to a service + * that allows sending a file for uploading via POST request. + * @param {String} params.renderMarkdown A function that accepts a markdown string + * and returns a rendered version in HTML format. + * @param {File} params.file The file to upload + * + * @returns Returns an object with two properties: + * + * canonicalSrc: The URL as defined in the Markdown + * src: The absolute URL that points to the resource in the server + */ +export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => { + const formData = new FormData(); + formData.append('file', file, file.name); + + const { data } = await axios.post(uploadsPath, formData); + const { markdown } = data.link; + const rendered = await renderMarkdown(markdown); + + return extractAttachmentLinkUrl(rendered); +}; + +const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => { + const encodedSrc = await readFileAsDataURL(file); + const { view } = editor; + + editor.commands.setImage({ uploading: true, src: encodedSrc }); + + const { state } = view; + const position = state.selection.from - 1; + const { tr } = state; + + try { + const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown }); + + view.dispatch( + tr.setNodeMarkup(position, undefined, { + uploading: false, + src: encodedSrc, + alt: extractFilename(src), + canonicalSrc, + }), + ); + } catch (e) { + editor.commands.deleteRange({ from: position, to: position + 1 }); + editor.emit('error', { + error: __('An error occurred while uploading the image. Please try again.'), + }); + } +}; + +const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) => { + await Promise.resolve(); + + const { view } = editor; + + const text = extractFilename(file.name); + + const { state } = view; + const { from } = state.selection; + + editor.commands.insertContent({ + type: 'loading', + attrs: { label: text }, + }); + + try { + const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown }); + + editor.commands.insertContentAt( + { from, to: from + 1 }, + { type: 'text', text, marks: [{ type: 'link', attrs: { href: src, canonicalSrc } }] }, + ); + } catch (e) { + editor.commands.deleteRange({ from, to: from + 1 }); + editor.emit('error', { + error: __('An error occurred while uploading the file. Please try again.'), + }); + } +}; + +export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown }) => { + if (!file) return false; + + if (acceptedMimes.image.includes(file?.type)) { + uploadImage({ editor, file, uploadsPath, renderMarkdown }); + + return true; + } + + uploadAttachment({ editor, file, uploadsPath, renderMarkdown }); + + return true; +}; diff --git a/app/assets/javascripts/content_editor/services/utils.js b/app/assets/javascripts/content_editor/services/utils.js index 2a2c7f617da..b3856b0dd74 100644 --- a/app/assets/javascripts/content_editor/services/utils.js +++ b/app/assets/javascripts/content_editor/services/utils.js @@ -4,8 +4,18 @@ export const hasSelection = (tiptapEditor) => { return from < to; }; -export const getImageAlt = (src) => { - return src.replace(/^.*\/|\..*$/g, '').replace(/\W+/g, ' '); +/** + * Extracts filename from a URL + * + * @example + * > extractFilename('https://gitlab.com/images/logo-full.png') + * < 'logo-full' + * + * @param {string} src The URL to extract filename from + * @returns {string} + */ +export const extractFilename = (src) => { + return src.replace(/^.*\/|\..+?$/g, ''); }; export const readFileAsDataURL = (file) => { diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue index f104eb61e41..45c886978f1 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue @@ -78,7 +78,7 @@ export default { return sprintf( s__( - 'ClusterIntegration|The Amazon Resource Name (ARN) associated with your role. If you do not have a provision role, first create one on %{startAwsLink}Amazon Web Services %{externalLinkIcon}%{endLink} using the above account and external IDs. %{startMoreInfoLink}More information%{endLink}', + 'ClusterIntegration|The Amazon Resource Name (ARN) associated with your role. If you do not have a provisioned role, first create one on %{startAwsLink}Amazon Web Services %{externalLinkIcon}%{endLink} using the above account and external IDs. %{startMoreInfoLink}More information%{endLink}', ), { startAwsLink: diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue index 8492f0b73e1..c9ecac6829b 100644 --- a/app/assets/javascripts/cycle_analytics/components/base.vue +++ b/app/assets/javascripts/cycle_analytics/components/base.vue @@ -1,16 +1,12 @@ <script> -import { GlIcon, GlEmptyState, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import { GlIcon, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import Cookies from 'js-cookie'; import { mapActions, mapState, mapGetters } from 'vuex'; import PathNavigation from '~/cycle_analytics/components/path_navigation.vue'; +import StageTable from '~/cycle_analytics/components/stage_table.vue'; +import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue'; import { __ } from '~/locale'; -import banner from './banner.vue'; -import stageCodeComponent from './stage_code_component.vue'; -import stageComponent from './stage_component.vue'; -import stageNavItem from './stage_nav_item.vue'; -import stageReviewComponent from './stage_review_component.vue'; -import stageStagingComponent from './stage_staging_component.vue'; -import stageTestComponent from './stage_test_component.vue'; +import { SUMMARY_METRICS_REQUEST, METRICS_REQUESTS } from '../constants'; const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed'; @@ -18,19 +14,11 @@ export default { name: 'CycleAnalytics', components: { GlIcon, - GlEmptyState, GlLoadingIcon, GlSprintf, - banner, - 'stage-issue-component': stageComponent, - 'stage-plan-component': stageComponent, - 'stage-code-component': stageCodeComponent, - 'stage-test-component': stageTestComponent, - 'stage-review-component': stageReviewComponent, - 'stage-staging-component': stageStagingComponent, - 'stage-production-component': stageComponent, - 'stage-nav-item': stageNavItem, PathNavigation, + StageTable, + ValueStreamMetrics, }, props: { noDataSvgPath: { @@ -57,30 +45,56 @@ export default { 'selectedStageError', 'stages', 'summary', - 'startDate', + 'daysInPast', 'permissions', + 'stageCounts', + 'endpoints', + 'features', ]), - ...mapGetters(['pathNavigationData']), + ...mapGetters(['pathNavigationData', 'filterParams']), displayStageEvents() { const { selectedStageEvents, isLoadingStage, isEmptyStage } = this; return selectedStageEvents.length && !isLoadingStage && !isEmptyStage; }, displayNotEnoughData() { - return this.selectedStageReady && this.isEmptyStage; + return !this.isLoadingStage && this.isEmptyStage; }, displayNoAccess() { - return this.selectedStageReady && !this.isUserAllowed(this.selectedStage.id); + return ( + !this.isLoadingStage && this.selectedStage?.id && !this.isUserAllowed(this.selectedStage.id) + ); }, - selectedStageReady() { - return !this.isLoadingStage && this.selectedStage; + displayPathNavigation() { + return this.isLoading || (this.selectedStage && this.pathNavigationData.length); }, emptyStageTitle() { + if (this.displayNoAccess) { + return __('You need permission.'); + } return this.selectedStageError ? this.selectedStageError : __("We don't have enough data to show this stage."); }, emptyStageText() { - return !this.selectedStageError ? this.selectedStage.emptyStageText : ''; + if (this.displayNoAccess) { + return __('Want to see the data? Please ask an administrator for access.'); + } + return !this.selectedStageError && this.selectedStage?.emptyStageText + ? this.selectedStage?.emptyStageText + : ''; + }, + selectedStageCount() { + if (this.selectedStage) { + const { + stageCounts, + selectedStage: { id }, + } = this; + return stageCounts[id]; + } + return 0; + }, + metricsRequests() { + return this.features?.cycleAnalyticsForGroups ? METRICS_REQUESTS : SUMMARY_METRICS_REQUEST; }, }, methods: { @@ -90,8 +104,8 @@ export default { 'setSelectedStage', 'setDateRange', ]), - handleDateSelect(startDate) { - this.setDateRange({ startDate }); + handleDateSelect(daysInPast) { + this.setDateRange(daysInPast); }, onSelectStage(stage) { this.setSelectedStage(stage); @@ -108,124 +122,62 @@ export default { dayRangeOptions: [7, 30, 90], i18n: { dropdownText: __('Last %{days} days'), + pageTitle: __('Value Stream Analytics'), + recentActivity: __('Recent Project Activity'), }, }; </script> <template> <div class="cycle-analytics"> - <path-navigation - v-if="selectedStageReady" - class="js-path-navigation gl-w-full gl-pb-2" - :loading="isLoading" - :stages="pathNavigationData" - :selected-stage="selectedStage" - :with-stage-counts="false" - @selected="onSelectStage" - /> - <gl-loading-icon v-if="isLoading" size="lg" /> - <div v-else class="wrapper"> - <!-- - We wont have access to the stage counts until we move to a default value stream - For now we can use the `withStageCounts` flag to ensure we don't display empty stage counts - Related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/326705 - --> - <div class="card" data-testid="vsa-stage-overview-metrics"> - <div class="card-header">{{ __('Recent Project Activity') }}</div> - <div class="d-flex justify-content-between"> - <div v-for="item in summary" :key="item.title" class="gl-flex-grow-1 gl-text-center"> - <h3 class="header">{{ item.value }}</h3> - <p class="text">{{ item.title }}</p> - </div> - <div class="flex-grow align-self-center text-center"> - <div class="js-ca-dropdown dropdown inline"> - <!-- eslint-disable-next-line @gitlab/vue-no-data-toggle --> - <button class="dropdown-menu-toggle" data-toggle="dropdown" type="button"> - <span class="dropdown-label"> - <gl-sprintf :message="$options.i18n.dropdownText"> - <template #days>{{ startDate }}</template> - </gl-sprintf> - <gl-icon name="chevron-down" class="dropdown-menu-toggle-icon gl-top-3" /> - </span> - </button> - <ul class="dropdown-menu dropdown-menu-right"> - <li v-for="days in $options.dayRangeOptions" :key="`day-range-${days}`"> - <a href="#" @click.prevent="handleDateSelect(days)"> - <gl-sprintf :message="$options.i18n.dropdownText"> - <template #days>{{ days }}</template> - </gl-sprintf> - </a> - </li> - </ul> - </div> - </div> - </div> - </div> - <div class="stage-panel-container" data-testid="vsa-stage-table"> - <div class="card stage-panel gl-px-5"> - <div class="card-header border-bottom-0"> - <nav class="col-headers"> - <ul class="gl-display-flex gl-justify-content-space-between gl-list-style-none"> - <li> - <span v-if="selectedStage" class="stage-name font-weight-bold">{{ - selectedStage.legend ? __(selectedStage.legend) : __('Related Issues') - }}</span> - <span - class="has-tooltip" - data-placement="top" - :title=" - __('The collection of events added to the data gathered for that stage.') - " - aria-hidden="true" - > - <gl-icon name="question-o" class="gl-text-gray-500" /> - </span> - </li> - <li> - <span class="stage-name font-weight-bold">{{ __('Time') }}</span> - <span - class="has-tooltip" - data-placement="top" - :title="__('The time taken by each data entry gathered by that stage.')" - aria-hidden="true" - > - <gl-icon name="question-o" class="gl-text-gray-500" /> - </span> - </li> - </ul> - </nav> - </div> - <div class="stage-panel-body"> - <section class="stage-events gl-overflow-auto gl-w-full"> - <gl-loading-icon v-if="isLoadingStage" size="lg" /> - <template v-else> - <gl-empty-state - v-if="displayNoAccess" - class="js-empty-state" - :title="__('You need permission.')" - :svg-path="noAccessSvgPath" - :description="__('Want to see the data? Please ask an administrator for access.')" - /> - <template v-else> - <gl-empty-state - v-if="displayNotEnoughData" - class="js-empty-state" - :description="emptyStageText" - :svg-path="noDataSvgPath" - :title="emptyStageTitle" - /> - <component - :is="selectedStage.component" - v-if="displayStageEvents" - :stage="selectedStage" - :items="selectedStageEvents" - data-testid="stage-table-events" - /> - </template> - </template> - </section> - </div> + <h3>{{ $options.i18n.pageTitle }}</h3> + <div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row"> + <path-navigation + v-if="displayPathNavigation" + class="js-path-navigation gl-w-full gl-pb-2" + :loading="isLoading || isLoadingStage" + :stages="pathNavigationData" + :selected-stage="selectedStage" + @selected="onSelectStage" + /> + <div class="gl-flex-grow gl-align-self-end"> + <div class="js-ca-dropdown dropdown inline"> + <!-- eslint-disable-next-line @gitlab/vue-no-data-toggle --> + <button class="dropdown-menu-toggle" data-toggle="dropdown" type="button"> + <span class="dropdown-label"> + <gl-sprintf :message="$options.i18n.dropdownText"> + <template #days>{{ daysInPast }}</template> + </gl-sprintf> + <gl-icon name="chevron-down" class="dropdown-menu-toggle-icon gl-top-3" /> + </span> + </button> + <ul class="dropdown-menu dropdown-menu-right"> + <li v-for="days in $options.dayRangeOptions" :key="`day-range-${days}`"> + <a href="#" @click.prevent="handleDateSelect(days)"> + <gl-sprintf :message="$options.i18n.dropdownText"> + <template #days>{{ days }}</template> + </gl-sprintf> + </a> + </li> + </ul> </div> </div> </div> + <value-stream-metrics + :request-path="endpoints.fullPath" + :request-params="filterParams" + :requests="metricsRequests" + /> + <gl-loading-icon v-if="isLoading" size="lg" /> + <stage-table + v-else + :is-loading="isLoading || isLoadingStage" + :stage-events="selectedStageEvents" + :selected-stage="selectedStage" + :stage-count="selectedStageCount" + :empty-state-title="emptyStageTitle" + :empty-state-message="emptyStageText" + :no-data-svg-path="noDataSvgPath" + :pagination="null" + /> </div> </template> diff --git a/app/assets/javascripts/cycle_analytics/components/path_navigation.vue b/app/assets/javascripts/cycle_analytics/components/path_navigation.vue index 47fafc3b90c..f8f89772fd6 100644 --- a/app/assets/javascripts/cycle_analytics/components/path_navigation.vue +++ b/app/assets/javascripts/cycle_analytics/components/path_navigation.vue @@ -34,12 +34,7 @@ export default { selectedStage: { type: Object, required: false, - default: () => {}, - }, - withStageCounts: { - type: Boolean, - required: false, - default: true, + default: () => ({}), }, }, methods: { @@ -81,7 +76,7 @@ export default { <div class="gl-pb-4 gl-font-weight-bold">{{ pathItem.metric }}</div> </div> </div> - <div v-if="withStageCounts" class="gl-px-4"> + <div class="gl-px-4"> <div class="gl-display-flex gl-justify-content-space-between"> <div class="gl-pr-4 gl-pb-4"> {{ s__('ValueStreamEvent|Items in stage') }} diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_code_component.vue deleted file mode 100644 index 6b757c6972a..00000000000 --- a/app/assets/javascripts/cycle_analytics/components/stage_code_component.vue +++ /dev/null @@ -1,56 +0,0 @@ -<script> -import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; -import limitWarning from './limit_warning_component.vue'; -import totalTime from './total_time_component.vue'; - -export default { - components: { - userAvatarImage, - limitWarning, - totalTime, - }, - props: { - items: { - type: Array, - default: () => [], - required: false, - }, - stage: { - type: Object, - default: () => ({}), - required: false, - }, - }, -}; -</script> -<template> - <div> - <div class="events-description"> - {{ stage.description }} - <limit-warning :count="items.length" /> - </div> - <ul class="stage-event-list"> - <li v-for="(mergeRequest, i) in items" :key="i" class="stage-event-item"> - <div class="item-details"> - <!-- FIXME: Pass an alt attribute here for accessibility --> - <user-avatar-image :img-src="mergeRequest.author.avatarUrl" /> - <h5 class="item-title merge-request-title"> - <a :href="mergeRequest.url"> {{ mergeRequest.title }} </a> - </h5> - <a :href="mergeRequest.url" class="issue-link"> !{{ mergeRequest.iid }} </a> · - <span> - {{ s__('OpenedNDaysAgo|Opened') }} - <a :href="mergeRequest.url" class="issue-date"> {{ mergeRequest.createdAt }} </a> - </span> - <span> - {{ s__('ByAuthor|by') }} - <a :href="mergeRequest.author.webUrl" class="issue-author-link"> - {{ mergeRequest.author.name }} - </a> - </span> - </div> - <div class="item-time"><total-time :time="mergeRequest.totalTime" /></div> - </li> - </ul> - </div> -</template> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_component.vue deleted file mode 100644 index cc7ae74dd3a..00000000000 --- a/app/assets/javascripts/cycle_analytics/components/stage_component.vue +++ /dev/null @@ -1,54 +0,0 @@ -<script> -import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; -import limitWarning from './limit_warning_component.vue'; -import totalTime from './total_time_component.vue'; - -export default { - components: { - userAvatarImage, - limitWarning, - totalTime, - }, - props: { - items: { - type: Array, - default: () => [], - required: false, - }, - stage: { - type: Object, - default: () => ({}), - required: false, - }, - }, -}; -</script> -<template> - <div> - <div class="events-description"> - {{ stage.description }} - <limit-warning :count="items.length" /> - </div> - <ul class="stage-event-list"> - <li v-for="(issue, i) in items" :key="i" class="stage-event-item"> - <div class="item-details"> - <!-- FIXME: Pass an alt attribute here for accessibility --> - <user-avatar-image :img-src="issue.author.avatarUrl" /> - <h5 class="item-title issue-title"> - <a :href="issue.url" class="issue-title"> {{ issue.title }} </a> - </h5> - <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a> · - <span> - {{ s__('OpenedNDaysAgo|Opened') }} - <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> - </span> - <span> - {{ s__('ByAuthor|by') }} - <a :href="issue.author.webUrl" class="issue-author-link"> {{ issue.author.name }} </a> - </span> - </div> - <div class="item-time"><total-time :time="issue.totalTime" /></div> - </li> - </ul> - </div> -</template> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue deleted file mode 100644 index 4b15bd55cbd..00000000000 --- a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue +++ /dev/null @@ -1,60 +0,0 @@ -<script> -export default { - name: 'StageNavItem', - props: { - isDefaultStage: { - type: Boolean, - default: false, - required: false, - }, - isActive: { - type: Boolean, - default: false, - required: false, - }, - isUserAllowed: { - type: Boolean, - required: true, - }, - title: { - type: String, - required: true, - }, - value: { - type: String, - default: '', - required: false, - }, - }, - computed: { - hasValue() { - return this.value && this.value.length > 0; - }, - }, -}; -</script> - -<template> - <li @click="$emit('select')"> - <div - :class="{ active: isActive }" - class="stage-nav-item d-flex pl-4 pr-4 m-0 mb-1 ml-2 rounded border-color-default border-style-solid border-width-1px" - > - <div - class="stage-nav-item-cell stage-name w-50 pr-2" - :class="{ 'font-weight-bold': isActive }" - > - {{ title }} - </div> - <div class="stage-nav-item-cell stage-median w-50"> - <template v-if="isUserAllowed"> - <span v-if="hasValue">{{ value }}</span> - <span v-else class="stage-empty">{{ __('Not enough data') }}</span> - </template> - <template v-else> - <span class="not-available">{{ __('Not available') }}</span> - </template> - </div> - </div> - </li> -</template> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_review_component.vue deleted file mode 100644 index 33b4e649ab0..00000000000 --- a/app/assets/javascripts/cycle_analytics/components/stage_review_component.vue +++ /dev/null @@ -1,70 +0,0 @@ -<script> -import { GlIcon } from '@gitlab/ui'; -import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; -import limitWarning from './limit_warning_component.vue'; -import totalTime from './total_time_component.vue'; - -export default { - components: { - userAvatarImage, - totalTime, - limitWarning, - GlIcon, - }, - props: { - items: { - type: Array, - default: () => [], - required: false, - }, - stage: { - type: Object, - default: () => ({}), - required: false, - }, - }, -}; -</script> -<template> - <div> - <div class="events-description"> - {{ stage.description }} - <limit-warning :count="items.length" /> - </div> - <ul class="stage-event-list"> - <li v-for="(mergeRequest, i) in items" :key="i" class="stage-event-item"> - <div class="item-details"> - <!-- FIXME: Pass an alt attribute here for accessibility --> - <user-avatar-image :img-src="mergeRequest.author.avatarUrl" /> - <h5 class="item-title merge-request-title"> - <a :href="mergeRequest.url"> {{ mergeRequest.title }} </a> - </h5> - <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> · - <span> - {{ s__('OpenedNDaysAgo|Opened') }} - <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> - </span> - <span> - {{ s__('ByAuthor|by') }} - <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ - mergeRequest.author.name - }}</a> - </span> - <template v-if="mergeRequest.state === 'closed'"> - <span class="merge-request-state"> - <gl-icon name="cancel" class="gl-vertical-align-text-bottom" /> - {{ __('CLOSED') }} - </span> - </template> - <template v-else> - <span v-if="mergeRequest.branch" class="merge-request-branch"> - <gl-icon :size="16" name="fork" /> - <a :href="mergeRequest.branch.url"> {{ mergeRequest.branch.name }} </a> - </span> - </template> - </div> - <div class="item-time"><total-time :time="mergeRequest.totalTime" /></div> - </li> - </ul> - </div> -</template> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue deleted file mode 100644 index 6d8f711c13b..00000000000 --- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue +++ /dev/null @@ -1,60 +0,0 @@ -<script> -import { GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; -import limitWarning from './limit_warning_component.vue'; -import totalTime from './total_time_component.vue'; - -export default { - components: { - userAvatarImage, - totalTime, - limitWarning, - GlIcon, - }, - directives: { - SafeHtml, - }, - props: { - items: { - type: Array, - default: () => [], - required: false, - }, - stage: { - type: Object, - default: () => ({}), - required: false, - }, - }, -}; -</script> -<template> - <div> - <div class="events-description"> - {{ stage.description }} - <limit-warning :count="items.length" /> - </div> - <ul class="stage-event-list"> - <li v-for="(build, i) in items" :key="i" class="stage-event-item item-build-component"> - <div class="item-details"> - <!-- FIXME: Pass an alt attribute here for accessibility --> - <user-avatar-image :img-src="build.author.avatarUrl" /> - <h5 class="item-title"> - <a :href="build.url" class="pipeline-id"> #{{ build.id }} </a> - <gl-icon :size="16" name="fork" /> - <a :href="build.branch.url" class="ref-name"> {{ build.branch.name }} </a> - <span class="icon-branch gl-text-gray-400"> - <gl-icon name="commit" :size="14" /> - </span> - <a :href="build.commitUrl" class="commit-sha"> {{ build.shortSha }} </a> - </h5> - <span> - <a :href="build.url" class="build-date"> {{ build.date }} </a> {{ s__('ByAuthor|by') }} - <a :href="build.author.webUrl" class="issue-author-link"> {{ build.author.name }} </a> - </span> - </div> - <div class="item-time"><total-time :time="build.totalTime" /></div> - </li> - </ul> - </div> -</template> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/cycle_analytics/components/stage_table.vue new file mode 100644 index 00000000000..0c47838c773 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/stage_table.vue @@ -0,0 +1,266 @@ +<script> +import { + GlEmptyState, + GlIcon, + GlLink, + GlLoadingIcon, + GlPagination, + GlTable, + GlBadge, +} from '@gitlab/ui'; +import FormattedStageCount from '~/cycle_analytics/components/formatted_stage_count.vue'; +import { __ } from '~/locale'; +import Tracking from '~/tracking'; +import { + NOT_ENOUGH_DATA_ERROR, + PAGINATION_SORT_FIELD_END_EVENT, + PAGINATION_SORT_FIELD_DURATION, + PAGINATION_SORT_DIRECTION_ASC, + PAGINATION_SORT_DIRECTION_DESC, +} from '../constants'; +import TotalTime from './total_time_component.vue'; + +const DEFAULT_WORKFLOW_TITLE_PROPERTIES = { + thClass: 'gl-w-half', + key: PAGINATION_SORT_FIELD_END_EVENT, + sortable: true, +}; +const WORKFLOW_COLUMN_TITLES = { + issues: { ...DEFAULT_WORKFLOW_TITLE_PROPERTIES, label: __('Issues') }, + jobs: { ...DEFAULT_WORKFLOW_TITLE_PROPERTIES, label: __('Jobs') }, + deployments: { ...DEFAULT_WORKFLOW_TITLE_PROPERTIES, label: __('Deployments') }, + mergeRequests: { ...DEFAULT_WORKFLOW_TITLE_PROPERTIES, label: __('Merge requests') }, +}; + +export default { + name: 'StageTable', + components: { + GlEmptyState, + GlIcon, + GlLink, + GlLoadingIcon, + GlPagination, + GlTable, + GlBadge, + TotalTime, + FormattedStageCount, + }, + mixins: [Tracking.mixin()], + props: { + selectedStage: { + type: Object, + required: false, + default: () => ({}), + }, + isLoading: { + type: Boolean, + required: true, + }, + stageEvents: { + type: Array, + required: true, + }, + stageCount: { + type: Number, + required: false, + default: null, + }, + noDataSvgPath: { + type: String, + required: true, + }, + emptyStateTitle: { + type: String, + required: false, + default: null, + }, + emptyStateMessage: { + type: String, + required: false, + default: '', + }, + pagination: { + type: Object, + required: false, + default: null, + }, + }, + data() { + if (this.pagination) { + const { + pagination: { sort, direction }, + } = this; + return { + sort, + direction, + sortDesc: direction === PAGINATION_SORT_DIRECTION_DESC, + }; + } + return { sort: null, direction: null, sortDesc: null }; + }, + computed: { + isEmptyStage() { + return !this.selectedStage || !this.stageEvents.length; + }, + emptyStateTitleText() { + return this.emptyStateTitle || NOT_ENOUGH_DATA_ERROR; + }, + isMergeRequestStage() { + const [firstEvent] = this.stageEvents; + return this.isMrLink(firstEvent.url); + }, + workflowTitle() { + if (this.isMergeRequestStage) { + return WORKFLOW_COLUMN_TITLES.mergeRequests; + } + return WORKFLOW_COLUMN_TITLES.issues; + }, + fields() { + return [ + this.workflowTitle, + { + key: PAGINATION_SORT_FIELD_DURATION, + label: __('Time'), + thClass: 'gl-w-half', + sortable: true, + }, + ]; + }, + prevPage() { + return Math.max(this.pagination.page - 1, 0); + }, + nextPage() { + return this.pagination.hasNextPage ? this.pagination.page + 1 : null; + }, + }, + methods: { + isMrLink(url = '') { + return url.includes('/merge_request'); + }, + itemId({ url, iid }) { + return this.isMrLink(url) ? `!${iid}` : `#${iid}`; + }, + itemTitle(item) { + return item.title || item.name; + }, + onSelectPage(page) { + const { sort, direction } = this.pagination; + this.track('click_button', { label: 'pagination' }); + this.$emit('handleUpdatePagination', { sort, direction, page }); + }, + onSort({ sortBy, sortDesc }) { + const direction = sortDesc ? PAGINATION_SORT_DIRECTION_DESC : PAGINATION_SORT_DIRECTION_ASC; + this.sort = sortBy; + this.sortDesc = sortDesc; + this.$emit('handleUpdatePagination', { sort: sortBy, direction }); + this.track('click_button', { label: `sort_${sortBy}_${direction}` }); + }, + }, +}; +</script> +<template> + <div data-testid="vsa-stage-table"> + <gl-loading-icon v-if="isLoading" class="gl-mt-4" size="md" /> + <gl-empty-state + v-else-if="isEmptyStage" + :title="emptyStateTitleText" + :description="emptyStateMessage" + :svg-path="noDataSvgPath" + /> + <gl-table + v-else + head-variant="white" + stacked="lg" + thead-class="border-bottom" + show-empty + :sort-by.sync="sort" + :sort-direction.sync="direction" + :sort-desc.sync="sortDesc" + :fields="fields" + :items="stageEvents" + :empty-text="emptyStateMessage" + @sort-changed="onSort" + > + <template v-if="stageCount" #head(end_event)="data"> + <span>{{ data.label }}</span + ><gl-badge class="gl-ml-2" size="sm" + ><formatted-stage-count :stage-count="stageCount" + /></gl-badge> + </template> + <template #cell(end_event)="{ item }"> + <div data-testid="vsa-stage-event"> + <div v-if="item.id" data-testid="vsa-stage-content"> + <p class="gl-m-0"> + <gl-link class="gl-text-black-normal pipeline-id" :href="item.url" + >#{{ item.id }}</gl-link + > + <gl-icon :size="16" name="fork" /> + <gl-link + v-if="item.branch" + :href="item.branch.url" + class="gl-text-black-normal ref-name" + >{{ item.branch.name }}</gl-link + > + <span class="icon-branch gl-text-gray-400"> + <gl-icon name="commit" :size="14" /> + </span> + <gl-link + class="commit-sha" + :href="item.commitUrl" + data-testid="vsa-stage-event-build-sha" + >{{ item.shortSha }}</gl-link + > + </p> + <p class="gl-m-0"> + <span data-testid="vsa-stage-event-build-author-and-date"> + <gl-link class="gl-text-black-normal build-date" :href="item.url">{{ + item.date + }}</gl-link> + {{ s__('ByAuthor|by') }} + <gl-link + class="gl-text-black-normal issue-author-link" + :href="item.author.webUrl" + >{{ item.author.name }}</gl-link + > + </span> + </p> + </div> + <div v-else data-testid="vsa-stage-content"> + <h5 class="gl-font-weight-bold gl-my-1" data-testid="vsa-stage-event-title"> + <gl-link class="gl-text-black-normal" :href="item.url">{{ itemTitle(item) }}</gl-link> + </h5> + <p class="gl-m-0"> + <gl-link class="gl-text-black-normal" :href="item.url">{{ itemId(item) }}</gl-link> + <span class="gl-font-lg">·</span> + <span data-testid="vsa-stage-event-date"> + {{ s__('OpenedNDaysAgo|Opened') }} + <gl-link class="gl-text-black-normal" :href="item.url">{{ + item.createdAt + }}</gl-link> + </span> + <span data-testid="vsa-stage-event-author"> + {{ s__('ByAuthor|by') }} + <gl-link class="gl-text-black-normal" :href="item.author.webUrl">{{ + item.author.name + }}</gl-link> + </span> + </p> + </div> + </div> + </template> + <template #cell(duration)="{ item }"> + <total-time :time="item.totalTime" data-testid="vsa-stage-event-time" /> + </template> + </gl-table> + <gl-pagination + v-if="pagination && !isLoading && !isEmptyStage" + :value="pagination.page" + :prev-page="prevPage" + :next-page="nextPage" + align="center" + class="gl-mt-3" + data-testid="vsa-stage-pagination" + @input="onSelectPage" + /> + </div> +</template> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue deleted file mode 100644 index c165c8cee78..00000000000 --- a/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue +++ /dev/null @@ -1,56 +0,0 @@ -<script> -import { GlIcon } from '@gitlab/ui'; -import limitWarning from './limit_warning_component.vue'; -import totalTime from './total_time_component.vue'; - -export default { - components: { - totalTime, - limitWarning, - GlIcon, - }, - props: { - items: { - type: Array, - default: () => [], - required: false, - }, - stage: { - type: Object, - default: () => ({}), - required: false, - }, - }, -}; -</script> -<template> - <div> - <div class="events-description"> - {{ stage.description }} - <limit-warning :count="items.length" /> - </div> - <ul class="stage-event-list"> - <li v-for="(build, i) in items" :key="i" class="stage-event-item item-build-component"> - <div class="item-details"> - <h5 class="item-title"> - <span class="icon-build-status gl-text-green-500"> - <gl-icon name="status_success" :size="14" /> - </span> - <a :href="build.url" class="item-build-name"> {{ build.name }} </a> · - <a :href="build.url" class="pipeline-id"> #{{ build.id }} </a> - <gl-icon :size="16" name="fork" /> - <a :href="build.branch.url" class="ref-name"> {{ build.branch.name }} </a> - <span class="icon-branch gl-text-gray-400"> - <gl-icon name="commit" :size="14" /> - </span> - <a :href="build.commitUrl" class="commit-sha"> {{ build.shortSha }} </a> - </h5> - <span> - <a :href="build.url" class="issue-date"> {{ build.date }} </a> - </span> - </div> - <div class="item-time"><total-time :time="build.totalTime" /></div> - </li> - </ul> - </div> -</template> diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.vue b/app/assets/javascripts/cycle_analytics/components/total_time_component.vue index f52438ca2cb..a5a90a56974 100644 --- a/app/assets/javascripts/cycle_analytics/components/total_time_component.vue +++ b/app/assets/javascripts/cycle_analytics/components/total_time_component.vue @@ -1,4 +1,6 @@ <script> +import { n__, s__ } from '~/locale'; + export default { props: { time: { @@ -11,24 +13,48 @@ export default { hasData() { return Object.keys(this.time).length; }, + calculatedTime() { + const { + time: { days = null, mins = null, hours = null, seconds = null }, + } = this; + + if (days) { + return { + duration: days, + units: n__('day', 'days', days), + }; + } + + if (hours) { + return { + duration: hours, + units: n__('Time|hr', 'Time|hrs', hours), + }; + } + + if (mins && !days) { + return { + duration: mins, + units: n__('Time|min', 'Time|mins', mins), + }; + } + + if ((seconds && this.hasData === 1) || seconds === 0) { + return { + duration: seconds, + units: s__('Time|s'), + }; + } + + return { duration: null, units: null }; + }, }, }; </script> <template> <span class="total-time"> <template v-if="hasData"> - <template v-if="time.days"> - {{ time.days }} <span> {{ n__('day', 'days', time.days) }} </span> - </template> - <template v-if="time.hours"> - {{ time.hours }} <span> {{ n__('Time|hr', 'Time|hrs', time.hours) }} </span> - </template> - <template v-if="time.mins && !time.days"> - {{ time.mins }} <span> {{ n__('Time|min', 'Time|mins', time.mins) }} </span> - </template> - <template v-if="(time.seconds && hasData === 1) || time.seconds === 0"> - {{ time.seconds }} <span> {{ s__('Time|s') }} </span> - </template> + {{ calculatedTime.duration }} <span>{{ calculatedTime.units }}</span> </template> <template v-else> -- </template> </span> diff --git a/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue b/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue new file mode 100644 index 00000000000..7371ffd2c7c --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue @@ -0,0 +1,107 @@ +<script> +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlPopover } from '@gitlab/ui'; +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { flatten } from 'lodash'; +import createFlash from '~/flash'; +import { sprintf, s__ } from '~/locale'; +import { METRICS_POPOVER_CONTENT } from '../constants'; +import { removeFlash, prepareTimeMetricsData } from '../utils'; + +const requestData = ({ request, endpoint, path, params, name }) => { + return request({ endpoint, params, requestPath: path }) + .then(({ data }) => data) + .catch(() => { + const message = sprintf( + s__( + 'ValueStreamAnalytics|There was an error while fetching value stream analytics %{requestTypeName} data.', + ), + { requestTypeName: name }, + ); + createFlash({ message }); + }); +}; + +const fetchMetricsData = (reqs = [], path, params) => { + const promises = reqs.map((r) => requestData({ ...r, path, params })); + return Promise.all(promises).then((responses) => + prepareTimeMetricsData(flatten(responses), METRICS_POPOVER_CONTENT), + ); +}; + +export default { + name: 'ValueStreamMetrics', + components: { + GlPopover, + GlSingleStat, + GlSkeletonLoading, + }, + props: { + requestPath: { + type: String, + required: true, + }, + requestParams: { + type: Object, + required: true, + }, + requests: { + type: Array, + required: true, + }, + }, + data() { + return { + metrics: [], + isLoading: false, + }; + }, + watch: { + requestParams() { + this.fetchData(); + }, + }, + mounted() { + this.fetchData(); + }, + methods: { + fetchData() { + removeFlash(); + this.isLoading = true; + return fetchMetricsData(this.requests, this.requestPath, this.requestParams) + .then((data) => { + this.metrics = data; + this.isLoading = false; + }) + .catch(() => { + this.isLoading = false; + }); + }, + }, +}; +</script> +<template> + <div class="gl-display-flex gl-flex-wrap" data-testid="vsa-time-metrics"> + <div v-if="isLoading" class="gl-h-auto gl-py-3 gl-pr-9 gl-my-6"> + <gl-skeleton-loading /> + </div> + <template v-else> + <div v-for="metric in metrics" :key="metric.key" class="gl-my-6 gl-pr-9"> + <gl-single-stat + :id="metric.key" + :value="`${metric.value}`" + :title="metric.label" + :unit="metric.unit || ''" + :should-animate="true" + :animation-decimal-places="1" + tabindex="0" + /> + <gl-popover :target="metric.key" placement="bottom"> + <template #title> + <span class="gl-display-block gl-text-left">{{ metric.label }}</span> + </template> + <span v-if="metric.description">{{ metric.description }}</span> + </gl-popover> + </div> + </template> + </div> +</template> diff --git a/app/assets/javascripts/cycle_analytics/constants.js b/app/assets/javascripts/cycle_analytics/constants.js index 97f502326e5..c1be2ce7096 100644 --- a/app/assets/javascripts/cycle_analytics/constants.js +++ b/app/assets/javascripts/cycle_analytics/constants.js @@ -1,3 +1,10 @@ +import { + getValueStreamMetrics, + METRIC_TYPE_SUMMARY, + METRIC_TYPE_TIME_SUMMARY, +} from '~/api/analytics_api'; +import { __, s__ } from '~/locale'; + export const DEFAULT_DAYS_IN_PAST = 30; export const DEFAULT_DAYS_TO_DISPLAY = 30; export const OVERVIEW_STAGE_ID = 'overview'; @@ -7,3 +14,55 @@ export const DEFAULT_VALUE_STREAM = { slug: 'default', name: 'default', }; + +export const NOT_ENOUGH_DATA_ERROR = s__( + "ValueStreamAnalyticsStage|We don't have enough data to show this stage.", +); + +export const PAGINATION_TYPE = 'keyset'; +export const PAGINATION_SORT_FIELD_END_EVENT = 'end_event'; +export const PAGINATION_SORT_FIELD_DURATION = 'duration'; +export const PAGINATION_SORT_DIRECTION_DESC = 'desc'; +export const PAGINATION_SORT_DIRECTION_ASC = 'asc'; + +export const I18N_VSA_ERROR_STAGES = __( + 'There was an error fetching value stream analytics stages.', +); +export const I18N_VSA_ERROR_STAGE_MEDIAN = __('There was an error fetching median data for stages'); +export const I18N_VSA_ERROR_SELECTED_STAGE = __( + 'There was an error fetching data for the selected stage', +); + +export const OVERVIEW_METRICS = { + TIME_SUMMARY: 'TIME_SUMMARY', + RECENT_ACTIVITY: 'RECENT_ACTIVITY', +}; + +export const METRICS_POPOVER_CONTENT = { + 'lead-time': { + description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'), + }, + 'cycle-time': { + description: s__( + 'ValueStreamAnalytics|Median time from issue first merge request created to issue closed.', + ), + }, + 'new-issue': { description: s__('ValueStreamAnalytics|Number of new issues created.') }, + 'new-issues': { description: s__('ValueStreamAnalytics|Number of new issues created.') }, + deploys: { description: s__('ValueStreamAnalytics|Total number of deploys to production.') }, + 'deployment-frequency': { + description: s__('ValueStreamAnalytics|Average number of deployments to production per day.'), + }, + commits: { + description: s__('ValueStreamAnalytics|Number of commits pushed to the default branch'), + }, +}; + +export const SUMMARY_METRICS_REQUEST = [ + { endpoint: METRIC_TYPE_SUMMARY, name: __('recent activity'), request: getValueStreamMetrics }, +]; + +export const METRICS_REQUESTS = [ + { endpoint: METRIC_TYPE_TIME_SUMMARY, name: __('time summary'), request: getValueStreamMetrics }, + ...SUMMARY_METRICS_REQUEST, +]; diff --git a/app/assets/javascripts/cycle_analytics/default_event_objects.js b/app/assets/javascripts/cycle_analytics/default_event_objects.js deleted file mode 100644 index 57f9019d2f8..00000000000 --- a/app/assets/javascripts/cycle_analytics/default_event_objects.js +++ /dev/null @@ -1,98 +0,0 @@ -export default { - issue: { - created_at: '', - url: '', - iid: '', - title: '', - total_time: {}, - author: { - avatar_url: '', - id: '', - name: '', - web_url: '', - }, - }, - plan: { - title: '', - commit_url: '', - short_sha: '', - total_time: {}, - author: { - name: '', - id: '', - avatar_url: '', - web_url: '', - }, - }, - code: { - title: '', - iid: '', - created_at: '', - url: '', - total_time: {}, - author: { - name: '', - id: '', - avatar_url: '', - web_url: '', - }, - }, - test: { - name: '', - id: '', - date: '', - url: '', - short_sha: '', - commit_url: '', - total_time: {}, - branch: { - name: '', - url: '', - }, - }, - review: { - title: '', - iid: '', - created_at: '', - url: '', - state: '', - total_time: {}, - author: { - name: '', - id: '', - avatar_url: '', - web_url: '', - }, - }, - staging: { - id: '', - short_sha: '', - date: '', - url: '', - commit_url: '', - total_time: {}, - author: { - name: '', - id: '', - avatar_url: '', - web_url: '', - }, - branch: { - name: '', - url: '', - }, - }, - production: { - title: '', - created_at: '', - url: '', - iid: '', - total_time: {}, - author: { - name: '', - id: '', - avatar_url: '', - web_url: '', - }, - }, -}; diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/cycle_analytics/index.js index 615f96c3860..3827db4d9b2 100644 --- a/app/assets/javascripts/cycle_analytics/index.js +++ b/app/assets/javascripts/cycle_analytics/index.js @@ -20,11 +20,12 @@ export default () => { store.dispatch('initializeVsa', { projectId: parseInt(projectId, 10), groupPath, - requestPath, - fullPath, + endpoints: { + requestPath, + fullPath, + }, features: { - cycleAnalyticsForGroups: - (groupPath && gon?.licensed_features?.cycleAnalyticsForGroups) || false, + cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups), }, }); diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/cycle_analytics/store/actions.js index 955f0c7271e..a7a2c8ea9d3 100644 --- a/app/assets/javascripts/cycle_analytics/store/actions.js +++ b/app/assets/javascripts/cycle_analytics/store/actions.js @@ -1,25 +1,29 @@ import { getProjectValueStreamStages, getProjectValueStreams, - getProjectValueStreamStageData, getProjectValueStreamMetrics, getValueStreamStageMedian, + getValueStreamStageRecords, + getValueStreamStageCounts, } from '~/api/analytics_api'; import createFlash from '~/flash'; import { __ } from '~/locale'; -import { DEFAULT_DAYS_TO_DISPLAY, DEFAULT_VALUE_STREAM } from '../constants'; +import { DEFAULT_VALUE_STREAM, I18N_VSA_ERROR_STAGE_MEDIAN } from '../constants'; import * as types from './mutation_types'; export const setSelectedValueStream = ({ commit, dispatch }, valueStream) => { commit(types.SET_SELECTED_VALUE_STREAM, valueStream); - return dispatch('fetchValueStreamStages'); + return Promise.all([dispatch('fetchValueStreamStages'), dispatch('fetchCycleAnalyticsData')]); }; export const fetchValueStreamStages = ({ commit, state }) => { - const { fullPath, selectedValueStream } = state; + const { + endpoints: { fullPath }, + selectedValueStream: { id }, + } = state; commit(types.REQUEST_VALUE_STREAM_STAGES); - return getProjectValueStreamStages(fullPath, selectedValueStream.id) + return getProjectValueStreamStages(fullPath, id) .then(({ data }) => commit(types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS, data)) .catch(({ response: { status } }) => { commit(types.RECEIVE_VALUE_STREAM_STAGES_ERROR, status); @@ -37,16 +41,11 @@ export const receiveValueStreamsSuccess = ({ commit, dispatch }, data = []) => { export const fetchValueStreams = ({ commit, dispatch, state }) => { const { - fullPath, - features: { cycleAnalyticsForGroups }, + endpoints: { fullPath }, } = state; commit(types.REQUEST_VALUE_STREAMS); - const stageRequests = ['setSelectedStage']; - if (cycleAnalyticsForGroups) { - stageRequests.push('fetchStageMedians'); - } - + const stageRequests = ['setSelectedStage', 'fetchStageMedians', 'fetchStageCountValues']; return getProjectValueStreams(fullPath) .then(({ data }) => dispatch('receiveValueStreamsSuccess', data)) .then(() => Promise.all(stageRequests.map((r) => dispatch(r)))) @@ -54,9 +53,10 @@ export const fetchValueStreams = ({ commit, dispatch, state }) => { commit(types.RECEIVE_VALUE_STREAMS_ERROR, status); }); }; - export const fetchCycleAnalyticsData = ({ - state: { requestPath }, + state: { + endpoints: { requestPath }, + }, getters: { legacyFilterParams }, commit, }) => { @@ -72,18 +72,10 @@ export const fetchCycleAnalyticsData = ({ }); }; -export const fetchStageData = ({ - state: { requestPath, selectedStage }, - getters: { legacyFilterParams }, - commit, -}) => { +export const fetchStageData = ({ getters: { requestParams, filterParams }, commit }) => { commit(types.REQUEST_STAGE_DATA); - return getProjectValueStreamStageData({ - requestPath, - stageId: selectedStage.id, - params: legacyFilterParams, - }) + return getValueStreamStageRecords(requestParams, filterParams) .then(({ data }) => { // when there's a query timeout, the request succeeds but the error is encoded in the response data if (data?.error) { @@ -120,8 +112,37 @@ export const fetchStageMedians = ({ .then((data) => commit(types.RECEIVE_STAGE_MEDIANS_SUCCESS, data)) .catch((error) => { commit(types.RECEIVE_STAGE_MEDIANS_ERROR, error); + createFlash({ message: I18N_VSA_ERROR_STAGE_MEDIAN }); + }); +}; + +const getStageCounts = ({ stageId, vsaParams, filterParams = {} }) => { + return getValueStreamStageCounts({ ...vsaParams, stageId }, filterParams).then(({ data }) => ({ + id: stageId, + ...data, + })); +}; + +export const fetchStageCountValues = ({ + state: { stages }, + getters: { requestParams: vsaParams, filterParams }, + commit, +}) => { + commit(types.REQUEST_STAGE_COUNTS); + return Promise.all( + stages.map(({ id: stageId }) => + getStageCounts({ + vsaParams, + stageId, + filterParams, + }), + ), + ) + .then((data) => commit(types.RECEIVE_STAGE_COUNTS_SUCCESS, data)) + .catch((error) => { + commit(types.RECEIVE_STAGE_COUNTS_ERROR, error); createFlash({ - message: __('There was an error fetching median data for stages'), + message: __('There was an error fetching stage total counts'), }); }); }; @@ -132,22 +153,32 @@ export const setSelectedStage = ({ dispatch, commit, state: { stages } }, select return dispatch('fetchStageData'); }; -const refetchData = (dispatch, commit) => { - commit(types.SET_LOADING, true); +export const setLoading = ({ commit }, value) => commit(types.SET_LOADING, value); + +const refetchStageData = (dispatch) => { return Promise.resolve() - .then(() => dispatch('fetchValueStreams')) - .then(() => dispatch('fetchCycleAnalyticsData')) - .finally(() => commit(types.SET_LOADING, false)); + .then(() => dispatch('setLoading', true)) + .then(() => + Promise.all([ + dispatch('fetchCycleAnalyticsData'), + dispatch('fetchStageData'), + dispatch('fetchStageMedians'), + ]), + ) + .finally(() => dispatch('setLoading', false)); }; -export const setFilters = ({ dispatch, commit }) => refetchData(dispatch, commit); +export const setFilters = ({ dispatch }) => refetchStageData(dispatch); -export const setDateRange = ({ dispatch, commit }, { startDate = DEFAULT_DAYS_TO_DISPLAY }) => { - commit(types.SET_DATE_RANGE, { startDate }); - return refetchData(dispatch, commit); +export const setDateRange = ({ dispatch, commit }, daysInPast) => { + commit(types.SET_DATE_RANGE, daysInPast); + return refetchStageData(dispatch); }; export const initializeVsa = ({ commit, dispatch }, initialData = {}) => { commit(types.INITIALIZE_VSA, initialData); - return refetchData(dispatch, commit); + + return dispatch('setLoading', true) + .then(() => dispatch('fetchValueStreams')) + .finally(() => dispatch('setLoading', false)); }; diff --git a/app/assets/javascripts/cycle_analytics/store/getters.js b/app/assets/javascripts/cycle_analytics/store/getters.js index 66971ea8a2e..9faccabcaad 100644 --- a/app/assets/javascripts/cycle_analytics/store/getters.js +++ b/app/assets/javascripts/cycle_analytics/store/getters.js @@ -13,11 +13,11 @@ export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage export const requestParams = (state) => { const { - selectedStage: { id: stageId = null }, - groupPath: groupId, + endpoints: { fullPath }, selectedValueStream: { id: valueStreamId }, + selectedStage: { id: stageId = null }, } = state; - return { valueStreamId, groupId, stageId }; + return { requestPath: fullPath, valueStreamId, stageId }; }; const dateRangeParams = ({ createdAfter, createdBefore }) => ({ @@ -25,15 +25,14 @@ const dateRangeParams = ({ createdAfter, createdBefore }) => ({ created_before: createdBefore ? dateFormat(createdBefore, dateFormats.isoDate) : null, }); -export const legacyFilterParams = ({ startDate }) => { +export const legacyFilterParams = ({ daysInPast }) => { return { - 'cycle_analytics[start_date]': startDate, + 'cycle_analytics[start_date]': daysInPast, }; }; -export const filterParams = ({ id, ...rest }) => { +export const filterParams = (state) => { return { - project_ids: [id], - ...dateRangeParams(rest), + ...dateRangeParams(state), }; }; diff --git a/app/assets/javascripts/cycle_analytics/store/mutation_types.js b/app/assets/javascripts/cycle_analytics/store/mutation_types.js index 11ed62a4081..0d94aad2ca5 100644 --- a/app/assets/javascripts/cycle_analytics/store/mutation_types.js +++ b/app/assets/javascripts/cycle_analytics/store/mutation_types.js @@ -24,3 +24,7 @@ export const RECEIVE_STAGE_DATA_ERROR = 'RECEIVE_STAGE_DATA_ERROR'; export const REQUEST_STAGE_MEDIANS = 'REQUEST_STAGE_MEDIANS'; export const RECEIVE_STAGE_MEDIANS_SUCCESS = 'RECEIVE_STAGE_MEDIANS_SUCCESS'; export const RECEIVE_STAGE_MEDIANS_ERROR = 'RECEIVE_STAGE_MEDIANS_ERROR'; + +export const REQUEST_STAGE_COUNTS = 'REQUEST_STAGE_COUNTS'; +export const RECEIVE_STAGE_COUNTS_SUCCESS = 'RECEIVE_STAGE_COUNTS_SUCCESS'; +export const RECEIVE_STAGE_COUNTS_ERROR = 'RECEIVE_STAGE_COUNTS_ERROR'; diff --git a/app/assets/javascripts/cycle_analytics/store/mutations.js b/app/assets/javascripts/cycle_analytics/store/mutations.js index a8b7a607b66..e41de85c1fa 100644 --- a/app/assets/javascripts/cycle_analytics/store/mutations.js +++ b/app/assets/javascripts/cycle_analytics/store/mutations.js @@ -1,19 +1,11 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { DEFAULT_DAYS_TO_DISPLAY } from '../constants'; -import { - decorateData, - decorateEvents, - formatMedianValues, - calculateFormattedDayInPast, -} from '../utils'; +import { formatMedianValues, calculateFormattedDayInPast } from '../utils'; import * as types from './mutation_types'; export default { - [types.INITIALIZE_VSA](state, { requestPath, fullPath, groupPath, projectId, features }) { - state.requestPath = requestPath; - state.fullPath = fullPath; - state.groupPath = groupPath; - state.id = projectId; + [types.INITIALIZE_VSA](state, { endpoints, features }) { + state.endpoints = endpoints; const { now, past } = calculateFormattedDayInPast(DEFAULT_DAYS_TO_DISPLAY); state.createdBefore = now; state.createdAfter = past; @@ -28,9 +20,9 @@ export default { [types.SET_SELECTED_STAGE](state, stage) { state.selectedStage = stage; }, - [types.SET_DATE_RANGE](state, { startDate }) { - state.startDate = startDate; - const { now, past } = calculateFormattedDayInPast(startDate); + [types.SET_DATE_RANGE](state, daysInPast) { + state.daysInPast = daysInPast; + const { now, past } = calculateFormattedDayInPast(daysInPast); state.createdBefore = now; state.createdAfter = past; }, @@ -47,13 +39,7 @@ export default { state.stages = []; }, [types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS](state, { stages = [] }) { - state.stages = stages.map((s) => ({ - ...convertObjectPropsToCamelCase(s, { deep: true }), - // NOTE: we set the component type here to match the current behaviour - // this can be removed when we migrate to the update stage table - // https://gitlab.com/gitlab-org/gitlab/-/issues/326704 - component: `stage-${s.id}-component`, - })); + state.stages = stages.map((s) => convertObjectPropsToCamelCase(s, { deep: true })); }, [types.RECEIVE_VALUE_STREAM_STAGES_ERROR](state) { state.stages = []; @@ -61,25 +47,14 @@ export default { [types.REQUEST_CYCLE_ANALYTICS_DATA](state) { state.isLoading = true; state.hasError = false; - if (!state.features.cycleAnalyticsForGroups) { - state.medians = {}; - } }, [types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) { - const { summary, medians } = decorateData(data); - if (!state.features.cycleAnalyticsForGroups) { - state.medians = formatMedianValues(medians); - } - state.permissions = data.permissions; - state.summary = summary; + state.permissions = data?.permissions || {}; state.hasError = false; }, [types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) { state.isLoading = false; state.hasError = true; - if (!state.features.cycleAnalyticsForGroups) { - state.medians = {}; - } }, [types.REQUEST_STAGE_DATA](state) { state.isLoadingStage = true; @@ -87,11 +62,12 @@ export default { state.selectedStageEvents = []; state.hasError = false; }, - [types.RECEIVE_STAGE_DATA_SUCCESS](state, { events = [] }) { - const { selectedStage } = state; + [types.RECEIVE_STAGE_DATA_SUCCESS](state, events = []) { state.isLoadingStage = false; state.isEmptyStage = !events.length; - state.selectedStageEvents = decorateEvents(events, selectedStage); + state.selectedStageEvents = events.map((ev) => + convertObjectPropsToCamelCase(ev, { deep: true }), + ); state.hasError = false; }, [types.RECEIVE_STAGE_DATA_ERROR](state, error) { @@ -110,4 +86,19 @@ export default { [types.RECEIVE_STAGE_MEDIANS_ERROR](state) { state.medians = {}; }, + [types.REQUEST_STAGE_COUNTS](state) { + state.stageCounts = {}; + }, + [types.RECEIVE_STAGE_COUNTS_SUCCESS](state, stageCounts = []) { + state.stageCounts = stageCounts.reduce( + (acc, { id, count }) => ({ + ...acc, + [id]: count, + }), + {}, + ); + }, + [types.RECEIVE_STAGE_COUNTS_ERROR](state) { + state.stageCounts = {}; + }, }; diff --git a/app/assets/javascripts/cycle_analytics/store/state.js b/app/assets/javascripts/cycle_analytics/store/state.js index 4d61077fb99..e6da3f609b2 100644 --- a/app/assets/javascripts/cycle_analytics/store/state.js +++ b/app/assets/javascripts/cycle_analytics/store/state.js @@ -1,11 +1,10 @@ import { DEFAULT_DAYS_TO_DISPLAY } from '../constants'; export default () => ({ - features: {}, id: null, - requestPath: '', - fullPath: '', - startDate: DEFAULT_DAYS_TO_DISPLAY, + features: {}, + endpoints: {}, + daysInPast: DEFAULT_DAYS_TO_DISPLAY, createdAfter: null, createdBefore: null, stages: [], @@ -18,10 +17,10 @@ export default () => ({ selectedStageEvents: [], selectedStageError: '', medians: {}, + stageCounts: {}, hasError: false, isLoading: false, isLoadingStage: false, isEmptyStage: false, permissions: {}, - parentPath: null, }); diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/cycle_analytics/utils.js index a1690dd1513..fa02fdf914a 100644 --- a/app/assets/javascripts/cycle_analytics/utils.js +++ b/app/assets/javascripts/cycle_analytics/utils.js @@ -1,38 +1,19 @@ import dateFormat from 'dateformat'; import { unescape } from 'lodash'; import { dateFormats } from '~/analytics/shared/constants'; +import { hideFlash } from '~/flash'; import { sanitize } from '~/lib/dompurify'; -import { roundToNearestHalf, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { roundToNearestHalf } from '~/lib/utils/common_utils'; import { getDateInPast } from '~/lib/utils/datetime/date_calculation_utility'; import { parseSeconds } from '~/lib/utils/datetime_utility'; +import { slugify } from '~/lib/utils/text_utility'; import { s__, sprintf } from '../locale'; -import DEFAULT_EVENT_OBJECTS from './default_event_objects'; -/** - * These `decorate` methods will be removed when me migrate to the - * new table layout https://gitlab.com/gitlab-org/gitlab/-/issues/326704 - */ -const mapToEvent = (event, stage) => { - return convertObjectPropsToCamelCase( - { - ...DEFAULT_EVENT_OBJECTS[stage.slug], - ...event, - }, - { deep: true }, - ); -}; - -export const decorateEvents = (events, stage) => events.map((event) => mapToEvent(event, stage)); - -const mapToSummary = ({ value, ...rest }) => ({ ...rest, value: value || '-' }); -const mapToMedians = ({ name: id, value }) => ({ id, value }); - -export const decorateData = (data = {}) => { - const { stats: stages, summary } = data; - return { - summary: summary?.map((item) => mapToSummary(item)) || [], - medians: stages?.map((item) => mapToMedians(item)) || [], - }; +export const removeFlash = (type = 'alert') => { + const flashEl = document.querySelector(`.flash-${type}`); + if (flashEl) { + hideFlash(flashEl); + } }; /** @@ -135,3 +116,36 @@ export const calculateFormattedDayInPast = (daysInPast, today = new Date()) => { past: toIsoFormat(getDateInPast(today, daysInPast)), }; }; + +/** + * @typedef {Object} MetricData + * @property {String} title - Title of the metric measured + * @property {String} value - String representing the decimal point value, e.g '1.5' + * @property {String} [unit] - String representing the decimal point value, e.g '1.5' + * + * @typedef {Object} TransformedMetricData + * @property {String} label - Title of the metric measured + * @property {String} value - String representing the decimal point value, e.g '1.5' + * @property {String} key - Slugified string based on the 'title' + * @property {String} description - String to display for a description + * @property {String} unit - String representing the decimal point value, e.g '1.5' + */ + +/** + * Prepares metric data to be rendered in the metric_card component + * + * @param {MetricData[]} data - The metric data to be rendered + * @param {Object} popoverContent - Key value pair of data to display in the popover + * @returns {TransformedMetricData[]} An array of metrics ready to render in the metric_card + */ + +export const prepareTimeMetricsData = (data = [], popoverContent = {}) => + data.map(({ title: label, ...rest }) => { + const key = slugify(label); + return { + ...rest, + label, + key, + description: popoverContent[key]?.description || '', + }; + }); diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js index a1dd12ff769..69f1d62539a 100644 --- a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js +++ b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js @@ -281,6 +281,7 @@ export class GitLabDropdown { $target && !$target.hasClass('dropdown-menu-close') && !$target.hasClass('dropdown-menu-close-icon') && + !$target.is('use') && !$target.data('isLink') ) { e.stopPropagation(); diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue index 78ba586ce37..813f87452d8 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue @@ -4,13 +4,16 @@ import { ApolloMutation } from 'vue-apollo'; import createFlash from '~/flash'; import { s__ } from '~/locale'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; +import { updateGlobalTodoCount } from '~/vue_shared/components/sidebar/todo_toggle/utils'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants'; import createNoteMutation from '../../graphql/mutations/create_note.mutation.graphql'; import toggleResolveDiscussionMutation from '../../graphql/mutations/toggle_resolve_discussion.mutation.graphql'; import activeDiscussionQuery from '../../graphql/queries/active_discussion.query.graphql'; +import getDesignQuery from '../../graphql/queries/get_design.query.graphql'; import allVersionsMixin from '../../mixins/all_versions'; import { hasErrors } from '../../utils/cache_update'; +import { extractDesign } from '../../utils/design_management_utils'; import { ADD_DISCUSSION_COMMENT_ERROR } from '../../utils/error_messages'; import DesignNote from './design_note.vue'; import DesignReplyForm from './design_reply_form.vue'; @@ -161,6 +164,19 @@ export default { }, toggleResolvedStatus() { this.isResolving = true; + + /** + * Get previous todo count + */ + const { defaultClient: client } = this.$apollo.provider.clients; + const sourceData = client.readQuery({ + query: getDesignQuery, + variables: this.designVariables, + }); + + const design = extractDesign(sourceData); + const prevTodoCount = design.currentUserTodos?.nodes?.length || 0; + this.$apollo .mutate({ mutation: toggleResolveDiscussionMutation, @@ -170,6 +186,10 @@ export default { if (data.errors?.length > 0) { this.$emit('resolve-discussion-error', data.errors[0]); } + const newTodoCount = + data?.discussionToggleResolve?.discussion?.noteable?.currentUserTodos?.nodes?.length || + 0; + updateGlobalTodoCount(newTodoCount - prevTodoCount); }) .catch((err) => { this.$emit('resolve-discussion-error', err); diff --git a/app/assets/javascripts/design_management/components/image.vue b/app/assets/javascripts/design_management/components/image.vue index e64ee4a5a34..8ab94cd2c4b 100644 --- a/app/assets/javascripts/design_management/components/image.vue +++ b/app/assets/javascripts/design_management/components/image.vue @@ -1,6 +1,8 @@ <script> import { GlIcon } from '@gitlab/ui'; import { throttle } from 'lodash'; +import { DESIGN_MARK_APP_START, DESIGN_MAIN_IMAGE_OUTPUT } from '~/performance/constants'; +import { performanceMarkAndMeasure } from '~/performance/utils'; export default { components: { @@ -39,7 +41,9 @@ export default { window.removeEventListener('resize', this.resizeThrottled, false); }, mounted() { - this.onImgLoad(); + if (!this.image) { + this.onImgLoad(); + } this.resizeThrottled = throttle(() => { // NOTE: if imageStyle is set, then baseImageSize @@ -53,6 +57,14 @@ export default { methods: { onImgLoad() { requestIdleCallback(this.setBaseImageSize, { timeout: 1000 }); + performanceMarkAndMeasure({ + measures: [ + { + name: DESIGN_MAIN_IMAGE_OUTPUT, + start: DESIGN_MARK_APP_START, + }, + ], + }); }, onImgError() { this.imageError = true; diff --git a/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue index 750f16bbe57..816d7ac7abf 100644 --- a/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue +++ b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue @@ -1,6 +1,8 @@ <script> import { GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui'; +import defaultAvatarUrl from 'images/no_avatar.png'; import { __, sprintf } from '~/locale'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import allVersionsMixin from '../../mixins/all_versions'; import { findVersionId } from '../../utils/design_management_utils'; @@ -9,6 +11,7 @@ export default { GlDropdown, GlDropdownItem, GlSprintf, + TimeAgo, }, mixins: [allVersionsMixin], computed: { @@ -58,6 +61,9 @@ export default { } return __('Version %{versionNumber}'); }, + getAvatarUrl(version) { + return version?.author?.avatarUrl || defaultAvatarUrl; + }, }, }; </script> @@ -68,14 +74,28 @@ export default { v-for="(version, index) in allVersions" :key="version.id" :is-check-item="true" + :is-check-centered="true" :is-checked="findVersionId(version.id) === currentVersionId" + :avatar-url="getAvatarUrl(version)" @click="routeToVersion(version.id)" > - <gl-sprintf :message="versionText(version.id)"> - <template #versionNumber> - {{ allVersions.length - index }} - </template> - </gl-sprintf> + <strong> + <gl-sprintf :message="versionText(version.id)"> + <template #versionNumber> + {{ allVersions.length - index }} + </template> + </gl-sprintf> + </strong> + + <div v-if="version.author" class="gl-text-gray-600 gl-mt-1"> + <div>{{ version.author.name }}</div> + <time-ago + v-if="version.createdAt" + class="text-1" + :time="version.createdAt" + tooltip-placement="bottom" + /> + </div> </gl-dropdown-item> </gl-dropdown> </template> diff --git a/app/assets/javascripts/design_management/graphql.js b/app/assets/javascripts/design_management/graphql.js index 9a0547ee9db..fa57537f74e 100644 --- a/app/assets/javascripts/design_management/graphql.js +++ b/app/assets/javascripts/design_management/graphql.js @@ -1,10 +1,11 @@ -import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; +import { defaultDataIdFromObject, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import produce from 'immer'; import { uniqueId } from 'lodash'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import axios from '~/lib/utils/axios_utils'; +import introspectionQueryResultData from './graphql/fragmentTypes.json'; import activeDiscussionQuery from './graphql/queries/active_discussion.query.graphql'; import getDesignQuery from './graphql/queries/get_design.query.graphql'; import typeDefs from './graphql/typedefs.graphql'; @@ -12,6 +13,10 @@ import { addPendingTodoToStore } from './utils/cache_update'; import { extractTodoIdFromDeletePath, createPendingTodo } from './utils/design_management_utils'; import { CREATE_DESIGN_TODO_EXISTS_ERROR } from './utils/error_messages'; +const fragmentMatcher = new IntrospectionFragmentMatcher({ + introspectionQueryResultData, +}); + Vue.use(VueApollo); const resolvers = { @@ -80,6 +85,7 @@ const defaultClient = createDefaultClient( } return defaultDataIdFromObject(object); }, + fragmentMatcher, }, typeDefs, assumeImmutableResults: true, diff --git a/app/assets/javascripts/design_management/graphql/fragmentTypes.json b/app/assets/javascripts/design_management/graphql/fragmentTypes.json new file mode 100644 index 00000000000..0953231ea4c --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/fragmentTypes.json @@ -0,0 +1 @@ +{"__schema":{"types":[{"kind":"INTERFACE","name":"User","possibleTypes":[{"name":"UserCore"}]},{"kind":"UNION","name":"NoteableType","possibleTypes":[{"name":"Design"},{"name":"Issue"},{"name":"MergeRequest"}]}]}} diff --git a/app/assets/javascripts/design_management/graphql/fragments/design_todo_item.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design_todo_item.fragment.graphql new file mode 100644 index 00000000000..3fe20705ce2 --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/fragments/design_todo_item.fragment.graphql @@ -0,0 +1,11 @@ +fragment DesignTodoItem on Design { + id + image + __typename + currentUserTodos(state: pending) { + nodes { + id + __typename + } + } +} diff --git a/app/assets/javascripts/design_management/graphql/mutations/create_image_diff_note.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/create_image_diff_note.mutation.graphql index 0b8400ac040..41c3f56f477 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/create_image_diff_note.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/create_image_diff_note.mutation.graphql @@ -1,4 +1,5 @@ #import "../fragments/design_note.fragment.graphql" +#import "../fragments/design_todo_item.fragment.graphql" mutation createImageDiffNote($input: CreateImageDiffNoteInput!) { createImageDiffNote(input: $input) { @@ -7,6 +8,11 @@ mutation createImageDiffNote($input: CreateImageDiffNoteInput!) { discussion { id replyId + noteable { + ... on Design { + ...DesignTodoItem + } + } notes { nodes { ...DesignNote diff --git a/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql index 1157fc05d5f..124f12ef018 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql @@ -1,11 +1,17 @@ #import "../fragments/design_note.fragment.graphql" #import "../fragments/discussion_resolved_status.fragment.graphql" +#import "../fragments/design_todo_item.fragment.graphql" mutation toggleResolveDiscussion($id: ID!, $resolve: Boolean!) { discussionToggleResolve(input: { id: $id, resolve: $resolve }) { discussion { id ...ResolvedStatus + noteable { + ... on Design { + ...DesignTodoItem + } + } notes { nodes { ...DesignNote diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js index aa9f377ef16..11666587265 100644 --- a/app/assets/javascripts/design_management/index.js +++ b/app/assets/javascripts/design_management/index.js @@ -1,4 +1,6 @@ import Vue from 'vue'; +import { DESIGN_MARK_APP_START, DESIGN_MEASURE_BEFORE_APP } from '~/performance/constants'; +import { performanceMarkAndMeasure } from '~/performance/utils'; import App from './components/app.vue'; import apolloProvider from './graphql'; import activeDiscussionQuery from './graphql/queries/active_discussion.query.graphql'; @@ -28,6 +30,16 @@ export default () => { projectPath, issueIid, }, + mounted() { + performanceMarkAndMeasure({ + mark: DESIGN_MARK_APP_START, + measures: [ + { + name: DESIGN_MEASURE_BEFORE_APP, + }, + ], + }); + }, render(createElement) { return createElement(App); }, diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue index 19bfa123487..48ee7068809 100644 --- a/app/assets/javascripts/design_management/pages/design/index.vue +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -1,10 +1,12 @@ <script> import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; +import { isNull } from 'lodash'; import Mousetrap from 'mousetrap'; import { ApolloMutation } from 'vue-apollo'; import { keysFor, ISSUE_CLOSE_DESIGN } from '~/behaviors/shortcuts/keybindings'; import createFlash from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; +import { updateGlobalTodoCount } from '~/vue_shared/components/sidebar/todo_toggle/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import DesignDestroyer from '../../components/design_destroyer.vue'; import DesignReplyForm from '../../components/design_notes/design_reply_form.vue'; @@ -93,6 +95,7 @@ export default { errorMessage: '', scale: DEFAULT_SCALE, resolvedDiscussionsExpanded: false, + prevCurrentUserTodos: null, }; }, apollo: { @@ -163,6 +166,13 @@ export default { resolvedDiscussions() { return this.discussions.filter((discussion) => discussion.resolved); }, + currentUserTodos() { + if (!this.design || !this.design.currentUserTodos) { + return null; + } + + return this.design.currentUserTodos?.nodes?.length; + }, }, watch: { resolvedDiscussions(val) { @@ -170,6 +180,9 @@ export default { this.resolvedDiscussionsExpanded = false; } }, + currentUserTodos(_, prevCurrentUserTodos) { + this.prevCurrentUserTodos = prevCurrentUserTodos; + }, }, mounted() { Mousetrap.bind(keysFor(ISSUE_CLOSE_DESIGN), this.closeDesign); @@ -272,9 +285,14 @@ export default { this.$refs.newDiscussionForm.focusInput(); } }, - closeCommentForm() { + closeCommentForm(data) { this.comment = ''; this.annotationCoordinates = null; + + if (data?.data && !isNull(this.prevCurrentUserTodos)) { + updateGlobalTodoCount(this.currentUserTodos - this.prevCurrentUserTodos); + this.prevCurrentUserTodos = this.currentUserTodos; + } }, closeDesign() { this.$router.push({ diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index e33b60f8ab5..d03b5cbc26b 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -14,9 +14,11 @@ import { } from '~/behaviors/shortcuts/keybindings'; import createFlash from '~/flash'; import { isSingleViewStyle } from '~/helpers/diffs_helper'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { parseBoolean } from '~/lib/utils/common_utils'; import { updateHistory } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; +import MrWidgetHowToMergeModal from '~/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue'; import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; import notesEventHub from '../../notes/event_hub'; @@ -46,12 +48,12 @@ import diffsEventHub from '../event_hub'; import { reviewStatuses } from '../utils/file_reviews'; import { diffsApp } from '../utils/performance'; import { fileByFile } from '../utils/preferences'; +import { queueRedisHllEvents } from '../utils/queue_events'; import CollapsedFilesWarning from './collapsed_files_warning.vue'; import CommitWidget from './commit_widget.vue'; import CompareVersions from './compare_versions.vue'; import DiffFile from './diff_file.vue'; import HiddenFilesWarning from './hidden_files_warning.vue'; -import MergeConflictWarning from './merge_conflict_warning.vue'; import NoChanges from './no_changes.vue'; import PreRenderer from './pre_renderer.vue'; import TreeList from './tree_list.vue'; @@ -64,7 +66,6 @@ export default { DiffFile, NoChanges, HiddenFilesWarning, - MergeConflictWarning, CollapsedFilesWarning, CommitWidget, TreeList, @@ -76,6 +77,7 @@ export default { DynamicScrollerItem, PreRenderer, VirtualScrollerScrollSync, + MrWidgetHowToMergeModal, }, alerts: { ALERT_OVERFLOW_HIDDEN, @@ -163,6 +165,21 @@ export default { required: false, default: () => ({}), }, + sourceProjectDefaultUrl: { + type: String, + required: false, + default: '', + }, + sourceProjectFullPath: { + type: String, + required: false, + default: '', + }, + isForked: { + type: Boolean, + required: false, + default: false, + }, }, data() { const treeWidth = @@ -172,7 +189,6 @@ export default { treeWidth, diffFilesLength: 0, virtualScrollCurrentIndex: -1, - disableVirtualScroller: false, }; }, computed: { @@ -203,6 +219,8 @@ export default { 'mrReviews', 'renderTreeList', 'showWhitespace', + 'targetBranchName', + 'branchName', ]), ...mapGetters('diffs', [ 'whichCollapsedTypes', @@ -337,29 +355,33 @@ export default { } if (window.gon?.features?.diffSettingsUsageData) { + const events = []; + if (this.renderTreeList) { - api.trackRedisHllUserEvent(TRACKING_FILE_BROWSER_TREE); + events.push(TRACKING_FILE_BROWSER_TREE); } else { - api.trackRedisHllUserEvent(TRACKING_FILE_BROWSER_LIST); + events.push(TRACKING_FILE_BROWSER_LIST); } if (this.diffViewType === INLINE_DIFF_VIEW_TYPE) { - api.trackRedisHllUserEvent(TRACKING_DIFF_VIEW_INLINE); + events.push(TRACKING_DIFF_VIEW_INLINE); } else { - api.trackRedisHllUserEvent(TRACKING_DIFF_VIEW_PARALLEL); + events.push(TRACKING_DIFF_VIEW_PARALLEL); } if (this.showWhitespace) { - api.trackRedisHllUserEvent(TRACKING_WHITESPACE_SHOW); + events.push(TRACKING_WHITESPACE_SHOW); } else { - api.trackRedisHllUserEvent(TRACKING_WHITESPACE_HIDE); + events.push(TRACKING_WHITESPACE_HIDE); } if (this.viewDiffsFileByFile) { - api.trackRedisHllUserEvent(TRACKING_SINGLE_FILE_MODE); + events.push(TRACKING_SINGLE_FILE_MODE); } else { - api.trackRedisHllUserEvent(TRACKING_MULTIPLE_FILES_MODE); + events.push(TRACKING_MULTIPLE_FILES_MODE); } + + queueRedisHllEvents(events); } }, beforeCreate() { @@ -414,6 +436,7 @@ export default { 'setShowTreeList', 'navigateToDiffFileIndex', 'setFileByFile', + 'disableVirtualScroller', ]), subscribeToEvents() { notesEventHub.$once('fetchDiffData', this.fetchData); @@ -506,9 +529,32 @@ export default { ); } - Mousetrap.bind(['ctrl+f', 'command+f'], () => { - this.disableVirtualScroller = true; - }); + if ( + window.gon?.features?.diffsVirtualScrolling || + window.gon?.features?.diffSearchingUsageData + ) { + let keydownTime; + Mousetrap.bind(['mod+f', 'mod+g'], () => { + keydownTime = new Date().getTime(); + }); + + window.addEventListener('blur', () => { + if (keydownTime) { + const delta = new Date().getTime() - keydownTime; + + // To make sure the user is using the find function we need to wait for blur + // and max 1000ms to be sure it the search box is filtered + if (delta >= 0 && delta < 1000) { + this.disableVirtualScroller(); + + if (window.gon?.features?.diffSearchingUsageData) { + api.trackRedisHllUserEvent('i_code_review_user_searches_diff'); + api.trackRedisCounterEvent('diff_searches'); + } + } + } + }); + } }, removeEventListeners() { Mousetrap.unbind(keysFor(MR_PREVIOUS_FILE_IN_DIFF)); @@ -568,6 +614,9 @@ export default { }, minTreeWidth: MIN_TREE_WIDTH, maxTreeWidth: MAX_TREE_WIDTH, + howToMergeDocsPath: helpPagePath('user/project/merge_requests/reviews/index.md', { + anchor: 'checkout-merge-requests-locally-through-the-head-ref', + }), }; </script> @@ -587,12 +636,6 @@ export default { :plain-diff-path="plainDiffPath" :email-patch-path="emailPatchPath" /> - <merge-conflict-warning - v-if="visibleWarning == $options.alerts.ALERT_MERGE_CONFLICT" - :limited="isLimitedContainer" - :resolution-path="conflictResolutionPath" - :mergeable="canMerge" - /> <collapsed-files-warning v-if="visibleWarning == $options.alerts.ALERT_COLLAPSED_FILES" :limited="isLimitedContainer" @@ -628,7 +671,7 @@ export default { <div v-if="isBatchLoading" class="loading"><gl-loading-icon size="lg" /></div> <template v-else-if="renderDiffFiles"> <dynamic-scroller - v-if="!disableVirtualScroller && isVirtualScrollingEnabled" + v-if="isVirtualScrollingEnabled" ref="virtualScroller" :items="diffs" :min-item-size="70" @@ -705,6 +748,15 @@ export default { <no-changes v-else :changes-empty-state-illustration="changesEmptyStateIllustration" /> </div> </div> + <mr-widget-how-to-merge-modal + :is-fork="isForked" + :can-merge="canMerge" + :source-branch="branchName" + :source-project-path="sourceProjectFullPath" + :target-branch="targetBranchName" + :source-project-default-url="sourceProjectDefaultUrl" + :reviewing-docs-path="$options.howToMergeDocsPath" + /> </div> </div> </template> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index dde5ea81e9a..933891d698c 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -1,5 +1,12 @@ <script> -import { GlButton, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml, GlSprintf } from '@gitlab/ui'; +import { + GlButton, + GlLoadingIcon, + GlSafeHtmlDirective as SafeHtml, + GlSprintf, + GlAlert, + GlModalDirective, +} from '@gitlab/ui'; import { escape } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; import { IdState } from 'vendor/vue-virtual-scroller'; @@ -19,7 +26,7 @@ import { EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN, } from '../constants'; import eventHub from '../event_hub'; -import { DIFF_FILE, GENERIC_ERROR } from '../i18n'; +import { DIFF_FILE, GENERIC_ERROR, CONFLICT_TEXT } from '../i18n'; import { collapsedType, getShortShaFromFile } from '../utils/diff_file'; import DiffContent from './diff_content.vue'; import DiffFileHeader from './diff_file_header.vue'; @@ -31,9 +38,11 @@ export default { GlButton, GlLoadingIcon, GlSprintf, + GlAlert, }, directives: { SafeHtml, + GlModalDirective, }, mixins: [glFeatureFlagsMixin(), IdState({ idProp: (vm) => vm.file.file_hash })], props: { @@ -83,6 +92,7 @@ export default { idState() { return { isLoadingCollapsedDiff: false, + hasLoadedCollapsedDiff: false, forkMessageVisible: false, hasToggled: false, }; @@ -92,7 +102,12 @@ export default { genericError: GENERIC_ERROR, }, computed: { - ...mapState('diffs', ['currentDiffFileId', 'codequalityDiff']), + ...mapState('diffs', [ + 'currentDiffFileId', + 'codequalityDiff', + 'conflictResolutionPath', + 'canMerge', + ]), ...mapGetters(['isNotesFetched']), ...mapGetters('diffs', ['getDiffFileDiscussions', 'isVirtualScrollingEnabled']), viewBlobHref() { @@ -181,7 +196,13 @@ export default { }, 'file.file_hash': { handler: function hashChangeWatch(newHash, oldHash) { - if (newHash && oldHash && !this.hasDiff && !this.preRender) { + if ( + newHash && + oldHash && + !this.hasDiff && + !this.preRender && + !this.idState.hasLoadedCollapsedDiff + ) { this.requestDiff(); } }, @@ -198,6 +219,8 @@ export default { if (this.hasDiff) { this.postRender(); + } else if (this.viewDiffsFileByFile && !this.isCollapsed) { + this.requestDiff(); } this.manageViewedEffects(); @@ -265,14 +288,22 @@ export default { } }, requestDiff() { - this.idState.isLoadingCollapsedDiff = true; + const { idState, file } = this; - this.loadCollapsedDiff(this.file) + idState.isLoadingCollapsedDiff = true; + + this.loadCollapsedDiff(file) .then(() => { - this.idState.isLoadingCollapsedDiff = false; - this.setRenderIt(this.file); + idState.isLoadingCollapsedDiff = false; + idState.hasLoadedCollapsedDiff = true; + + if (this.file.file_hash === file.file_hash) { + this.setRenderIt(this.file); + } }) .then(() => { + if (this.file.file_hash !== file.file_hash) return; + requestIdleCallback( () => { this.postRender(); @@ -282,7 +313,7 @@ export default { ); }) .catch(() => { - this.idState.isLoadingCollapsedDiff = false; + idState.isLoadingCollapsedDiff = false; createFlash({ message: this.$options.i18n.genericError, }); @@ -295,6 +326,7 @@ export default { this.idState.forkMessageVisible = false; }, }, + CONFLICT_TEXT, }; </script> @@ -373,6 +405,55 @@ export default { <div v-else v-safe-html="errorMessage" class="nothing-here-block"></div> </div> <template v-else> + <gl-alert + v-if="file.conflict_type" + variant="danger" + :dismissible="false" + data-testid="conflictsAlert" + > + {{ $options.CONFLICT_TEXT[file.conflict_type] }} + <template v-if="!canMerge"> + {{ __('Ask someone with write access to resolve it.') }} + </template> + <gl-sprintf + v-else-if="conflictResolutionPath" + :message=" + __( + 'You can %{gitlabLinkStart}resolve conflicts on GitLab%{gitlabLinkEnd} or %{resolveLocallyStart}resolve it locally%{resolveLocallyEnd}.', + ) + " + > + <template #gitlabLink="{ content }"> + <gl-button + :href="conflictResolutionPath" + variant="link" + class="gl-vertical-align-text-bottom" + >{{ content }}</gl-button + > + </template> + <template #resolveLocally="{ content }"> + <gl-button + v-gl-modal-directive="'modal-merge-info'" + variant="link" + class="gl-vertical-align-text-bottom" + >{{ content }}</gl-button + > + </template> + </gl-sprintf> + <gl-sprintf + v-else + :message="__('You can %{resolveLocallyStart}resolve it locally%{resolveLocallyEnd}.')" + > + <template #resolveLocally="{ content }"> + <gl-button + v-gl-modal-directive="'modal-merge-info'" + variant="link" + class="gl-vertical-align-text-bottom" + >{{ content }}</gl-button + > + </template> + </gl-sprintf> + </gl-alert> <div v-if="showWarning" class="collapsed-file-warning gl-p-7 gl-bg-orange-50 gl-text-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base" diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue index 1f3ec7092bc..e2f3f9cad7b 100644 --- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue +++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue @@ -75,6 +75,7 @@ export default { :key="note.id" :img-src="note.author.avatar_url" :tooltip-text="getTooltipText(note)" + lazy class="diff-comment-avatar js-diff-comment-avatar" @click.native="$emit('toggleLineDiscussions')" /> diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue index c310bd9f31a..db3ad074d2f 100644 --- a/app/assets/javascripts/diffs/components/diff_row.vue +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -134,22 +134,13 @@ export default { interopRightAttributes(props) { return getInteropNewSideAttributes(props.line.right); }, - conflictText: memoize( - (line) => { + lineContent: (line) => { + if (line.isConflictMarker) { return line.type === CONFLICT_MARKER_THEIR ? 'HEAD//our changes' : 'origin//their changes'; - }, - (line) => line.type, - ), - lineContent: memoize( - (line) => { - if (line.isConflictMarker) { - return line.type === CONFLICT_MARKER_THEIR ? 'HEAD//our changes' : 'origin//their changes'; - } + } - return line.rich_text; - }, - (line) => line.line_code, - ), + return line.rich_text; + }, CONFLICT_MARKER, CONFLICT_MARKER_THEIR, CONFLICT_OUR, diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue index 05d4fbe7c20..e8b4ff16aec 100644 --- a/app/assets/javascripts/diffs/components/diff_stats.vue +++ b/app/assets/javascripts/diffs/components/diff_stats.vue @@ -62,8 +62,8 @@ export default { </div> <div v-else class="diff-stats-contents"> <div v-if="hasDiffFiles" class="diff-stats-group"> - <gl-icon name="doc-code" class="diff-stats-icon text-secondary" /> - <span class="text-secondary bold">{{ diffFilesCountText }} {{ filesText }}</span> + <gl-icon name="doc-code" class="diff-stats-icon gl-text-gray-500" /> + <span class="gl-text-gray-500 bold">{{ diffFilesCountText }} {{ filesText }}</span> </div> <div class="diff-stats-group gl-text-green-600 gl-display-flex gl-align-items-center" diff --git a/app/assets/javascripts/diffs/components/settings_dropdown.vue b/app/assets/javascripts/diffs/components/settings_dropdown.vue index 178f93b651e..2d9ac76b3e4 100644 --- a/app/assets/javascripts/diffs/components/settings_dropdown.vue +++ b/app/assets/javascripts/diffs/components/settings_dropdown.vue @@ -60,14 +60,14 @@ export default { <gl-button :class="{ selected: !renderTreeList }" class="gl-w-half js-list-view" - @click="setRenderTreeList(false)" + @click="setRenderTreeList({ renderTreeList: false })" > {{ __('List view') }} </gl-button> <gl-button :class="{ selected: renderTreeList }" class="gl-w-half js-tree-view" - @click="setRenderTreeList(true)" + @click="setRenderTreeList({ renderTreeList: true })" > {{ __('Tree view') }} </gl-button> diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index f1cf556fde0..8dda5eadb16 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -111,6 +111,8 @@ export const CONFLICT_MARKER_OUR = 'conflict_marker_our'; export const CONFLICT_MARKER_THEIR = 'conflict_marker_their'; // Tracking events +export const DEFER_DURATION = 750; + export const TRACKING_CLICK_DIFF_VIEW_SETTING = 'i_code_review_click_diff_view_setting'; export const TRACKING_DIFF_VIEW_INLINE = 'i_code_review_diff_view_inline'; export const TRACKING_DIFF_VIEW_PARALLEL = 'i_code_review_diff_view_parallel'; diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js index a45fd92d0a9..e617890af2e 100644 --- a/app/assets/javascripts/diffs/i18n.js +++ b/app/assets/javascripts/diffs/i18n.js @@ -25,3 +25,25 @@ export const SETTINGS_DROPDOWN = { fileByFile: __('Show one file at a time'), preferences: __('Preferences'), }; + +export const CONFLICT_TEXT = { + both_modified: __('Conflict: This file was modified in both the source and target branches.'), + modified_source_removed_target: __( + 'Conflict: This file was modified in the source branch, but removed in the target branch.', + ), + modified_target_removed_source: __( + 'Conflict: This file was removed in the source branch, but modified in the target branch.', + ), + renamed_same_file: __( + 'Conflict: This file was renamed differently in the source and target branches.', + ), + removed_source_renamed_target: __( + 'Conflict: This file was removed in the source branch, but renamed in the target branch.', + ), + removed_target_renamed_source: __( + 'Conflict: This file was renamed in the source branch, but removed in the target branch.', + ), + both_added: __( + 'Conflict: This file was added both in the source and target branches, but with different contents.', + ), +}; diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index ea83523008c..bddc28c4758 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -2,6 +2,7 @@ import Cookies from 'js-cookie'; import Vue from 'vue'; import { mapActions, mapState, mapGetters } from 'vuex'; import { parseBoolean } from '~/lib/utils/common_utils'; +import { getParameterValues } from '~/lib/utils/url_utility'; import FindFile from '~/vue_shared/components/file_finder/index.vue'; import eventHub from '../notes/event_hub'; import diffsApp from './components/app.vue'; @@ -82,6 +83,9 @@ export default function initDiffsApp(store) { showWhitespaceDefault: parseBoolean(dataset.showWhitespaceDefault), viewDiffsFileByFile: parseBoolean(dataset.fileByFileDefault), defaultSuggestionCommitMessage: dataset.defaultSuggestionCommitMessage, + sourceProjectDefaultUrl: dataset.sourceProjectDefaultUrl, + sourceProjectFullPath: dataset.sourceProjectFullPath, + isForked: parseBoolean(dataset.isForked), }; }, computed: { @@ -93,7 +97,7 @@ export default function initDiffsApp(store) { const treeListStored = localStorage.getItem(TREE_LIST_STORAGE_KEY); const renderTreeList = treeListStored !== null ? parseBoolean(treeListStored) : true; - this.setRenderTreeList(renderTreeList); + this.setRenderTreeList({ renderTreeList, trackClick: false }); // NOTE: A "true" or "checked" value for `showWhitespace` is '0' not '1'. // Check for cookie and save that setting for future use. @@ -104,6 +108,7 @@ export default function initDiffsApp(store) { this.setShowWhitespace({ url: this.endpointUpdateUser, showWhitespace: hideWhitespace !== '1', + trackClick: false, }); Cookies.remove(DIFF_WHITESPACE_COOKIE_NAME); } else { @@ -111,8 +116,14 @@ export default function initDiffsApp(store) { this.setShowWhitespace({ showWhitespace: this.showWhitespaceDefault, updateDatabase: false, + trackClick: false, }); } + + const vScrollingParam = getParameterValues('virtual_scrolling')[0]; + if (vScrollingParam === 'false' || vScrollingParam === 'true') { + Cookies.set('diffs_virtual_scrolling', vScrollingParam); + } }, methods: { ...mapActions('diffs', ['setRenderTreeList', 'setShowWhitespace']), @@ -139,6 +150,9 @@ export default function initDiffsApp(store) { fileByFileUserPreference: this.viewDiffsFileByFile, defaultSuggestionCommitMessage: this.defaultSuggestionCommitMessage, rehydratedMrReviews: getReviewsForMergeRequest(mrPath), + sourceProjectDefaultUrl: this.sourceProjectDefaultUrl, + sourceProjectFullPath: this.sourceProjectFullPath, + isForked: this.isForked, }, }); }, diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 66510edf3db..f7bdbe94bac 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -1,6 +1,5 @@ import Cookies from 'js-cookie'; import Vue from 'vue'; -import api from '~/api'; import createFlash from '~/flash'; import { diffViewerModes } from '~/ide/constants'; import axios from '~/lib/utils/axios_utils'; @@ -50,6 +49,7 @@ import eventHub from '../event_hub'; import { isCollapsed } from '../utils/diff_file'; import { markFileReview, setReviewsForMergeRequest } from '../utils/file_reviews'; import { getDerivedMergeRequestInformation } from '../utils/merge_request'; +import { queueRedisHllEvents } from '../utils/queue_events'; import TreeWorker from '../workers/tree_worker'; import * as types from './mutation_types'; import { @@ -368,8 +368,7 @@ export const setInlineDiffViewType = ({ commit }) => { historyPushState(url); if (window.gon?.features?.diffSettingsUsageData) { - api.trackRedisHllUserEvent(TRACKING_CLICK_DIFF_VIEW_SETTING); - api.trackRedisHllUserEvent(TRACKING_DIFF_VIEW_INLINE); + queueRedisHllEvents([TRACKING_CLICK_DIFF_VIEW_SETTING, TRACKING_DIFF_VIEW_INLINE]); } }; @@ -381,8 +380,7 @@ export const setParallelDiffViewType = ({ commit }) => { historyPushState(url); if (window.gon?.features?.diffSettingsUsageData) { - api.trackRedisHllUserEvent(TRACKING_CLICK_DIFF_VIEW_SETTING); - api.trackRedisHllUserEvent(TRACKING_DIFF_VIEW_PARALLEL); + queueRedisHllEvents([TRACKING_CLICK_DIFF_VIEW_SETTING, TRACKING_DIFF_VIEW_PARALLEL]); } }; @@ -520,14 +518,14 @@ export const toggleActiveFileByHash = ({ commit }, hash) => { commit(types.VIEW_DIFF_FILE, hash); }; -export const scrollToFile = ({ state, commit }, path) => { +export const scrollToFile = ({ state, commit, getters }, path) => { if (!state.treeEntries[path]) return; const { fileHash } = state.treeEntries[path]; commit(types.VIEW_DIFF_FILE, fileHash); - if (window.gon?.features?.diffsVirtualScrolling) { + if (getters.isVirtualScrollingEnabled) { eventHub.$emit('scrollToFileHash', fileHash); setTimeout(() => { @@ -535,6 +533,10 @@ export const scrollToFile = ({ state, commit }, path) => { }); } else { document.location.hash = fileHash; + + setTimeout(() => { + handleLocationHash(); + }); } }; @@ -560,25 +562,27 @@ export const closeDiffFileCommentForm = ({ commit }, fileHash) => { commit(types.CLOSE_DIFF_FILE_COMMENT_FORM, fileHash); }; -export const setRenderTreeList = ({ commit }, renderTreeList) => { +export const setRenderTreeList = ({ commit }, { renderTreeList, trackClick = true }) => { commit(types.SET_RENDER_TREE_LIST, renderTreeList); localStorage.setItem(TREE_LIST_STORAGE_KEY, renderTreeList); - if (window.gon?.features?.diffSettingsUsageData) { - api.trackRedisHllUserEvent(TRACKING_CLICK_FILE_BROWSER_SETTING); + if (window.gon?.features?.diffSettingsUsageData && trackClick) { + const events = [TRACKING_CLICK_FILE_BROWSER_SETTING]; if (renderTreeList) { - api.trackRedisHllUserEvent(TRACKING_FILE_BROWSER_TREE); + events.push(TRACKING_FILE_BROWSER_TREE); } else { - api.trackRedisHllUserEvent(TRACKING_FILE_BROWSER_LIST); + events.push(TRACKING_FILE_BROWSER_LIST); } + + queueRedisHllEvents(events); } }; export const setShowWhitespace = async ( { state, commit }, - { url, showWhitespace, updateDatabase = true }, + { url, showWhitespace, updateDatabase = true, trackClick = true }, ) => { if (updateDatabase && Boolean(window.gon?.current_user_id)) { await axios.put(url || state.endpointUpdateUser, { show_whitespace_in_diffs: showWhitespace }); @@ -587,14 +591,16 @@ export const setShowWhitespace = async ( commit(types.SET_SHOW_WHITESPACE, showWhitespace); notesEventHub.$emit('refetchDiffData'); - if (window.gon?.features?.diffSettingsUsageData) { - api.trackRedisHllUserEvent(TRACKING_CLICK_WHITESPACE_SETTING); + if (window.gon?.features?.diffSettingsUsageData && trackClick) { + const events = [TRACKING_CLICK_WHITESPACE_SETTING]; if (showWhitespace) { - api.trackRedisHllUserEvent(TRACKING_WHITESPACE_SHOW); + events.push(TRACKING_WHITESPACE_SHOW); } else { - api.trackRedisHllUserEvent(TRACKING_WHITESPACE_HIDE); + events.push(TRACKING_WHITESPACE_HIDE); } + + queueRedisHllEvents(events); } }; @@ -815,13 +821,15 @@ export const setFileByFile = ({ state, commit }, { fileByFile }) => { Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, fileViewMode); if (window.gon?.features?.diffSettingsUsageData) { - api.trackRedisHllUserEvent(TRACKING_CLICK_SINGLE_FILE_SETTING); + const events = [TRACKING_CLICK_SINGLE_FILE_SETTING]; if (fileByFile) { - api.trackRedisHllUserEvent(TRACKING_SINGLE_FILE_MODE); + events.push(TRACKING_SINGLE_FILE_MODE); } else { - api.trackRedisHllUserEvent(TRACKING_MULTIPLE_FILES_MODE); + events.push(TRACKING_MULTIPLE_FILES_MODE); } + + queueRedisHllEvents(events); } return axios @@ -844,3 +852,5 @@ export function reviewFile({ commit, state }, { file, reviewed = true }) { setReviewsForMergeRequest(mrPath, reviews); commit(types.SET_MR_FILE_REVIEWS, reviews); } + +export const disableVirtualScroller = ({ commit }) => commit(types.DISABLE_VIRTUAL_SCROLLING); diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index 1b6a673925f..18bd8e5f1d8 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -1,3 +1,4 @@ +import Cookies from 'js-cookie'; import { getParameterValues } from '~/lib/utils/url_utility'; import { __, n__ } from '~/locale'; import { @@ -173,7 +174,20 @@ export function suggestionCommitMessage(state, _, rootState) { }); } -export const isVirtualScrollingEnabled = (state) => - !state.viewDiffsFileByFile && - (window.gon?.features?.diffsVirtualScrolling || - getParameterValues('virtual_scrolling')[0] === 'true'); +export const isVirtualScrollingEnabled = (state) => { + const vSrollerCookie = Cookies.get('diffs_virtual_scrolling'); + + if (state.disableVirtualScroller) { + return false; + } + + if (vSrollerCookie) { + return vSrollerCookie === 'true'; + } + + return ( + !state.viewDiffsFileByFile && + (window.gon?.features?.diffsVirtualScrolling || + getParameterValues('virtual_scrolling')[0] === 'true') + ); +}; diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index 348dd452698..d76361513d4 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -43,4 +43,5 @@ export default () => ({ defaultSuggestionCommitMessage: '', mrReviews: {}, latestDiff: true, + disableVirtualScroller: false, }); diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index 4641731c4b6..2c370221f40 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -47,3 +47,4 @@ export const SET_DIFF_FILE_VIEWER = 'SET_DIFF_FILE_VIEWER'; export const SET_SHOW_SUGGEST_POPOVER = 'SET_SHOW_SUGGEST_POPOVER'; export const TOGGLE_LINE_DISCUSSIONS = 'TOGGLE_LINE_DISCUSSIONS'; +export const DISABLE_VIRTUAL_SCROLLING = 'DISABLE_VIRTUAL_SCROLLING'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 9ff9a02d444..1aa83453bf7 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -362,4 +362,7 @@ export default { [types.SET_MR_FILE_REVIEWS](state, newReviews) { state.mrReviews = newReviews; }, + [types.DISABLE_VIRTUAL_SCROLLING](state) { + state.disableVirtualScroller = true; + }, }; diff --git a/app/assets/javascripts/diffs/utils/queue_events.js b/app/assets/javascripts/diffs/utils/queue_events.js new file mode 100644 index 00000000000..08fcc98d45f --- /dev/null +++ b/app/assets/javascripts/diffs/utils/queue_events.js @@ -0,0 +1,13 @@ +import { delay } from 'lodash'; +import api from '~/api'; +import { DEFER_DURATION } from '../constants'; + +function trackRedisHllUserEvent(event, deferDuration = 0) { + delay(() => api.trackRedisHllUserEvent(event), deferDuration); +} + +export function queueRedisHllEvents(events) { + events.forEach((event, index) => { + trackRedisHllUserEvent(event, DEFER_DURATION * (index + 1)); + }); +} diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js index 849ff91841a..d40d19000fb 100644 --- a/app/assets/javascripts/editor/constants.js +++ b/app/assets/javascripts/editor/constants.js @@ -28,3 +28,9 @@ export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance'; // '*.gitlab-ci.yml' regardless of project configuration. // https://gitlab.com/gitlab-org/gitlab/-/issues/293641 export const EXTENSION_CI_SCHEMA_FILE_NAME_MATCH = '.gitlab-ci.yml'; + +export const EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS = 'md'; +export const EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS = 'source-editor-preview'; +export const EXTENSION_MARKDOWN_PREVIEW_ACTION_ID = 'markdown-preview'; +export const EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH = 0.5; // 50% of the width +export const EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY = 250; // ms diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js index 997503a12f5..76e009164f7 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js @@ -1,6 +1,165 @@ +import { debounce } from 'lodash'; +import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants'; +import createFlash from '~/flash'; +import { sanitize } from '~/lib/dompurify'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import syntaxHighlight from '~/syntax_highlight'; +import { + EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS, + EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, + EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, + EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS, + EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY, +} from '../constants'; import { SourceEditorExtension } from './source_editor_extension_base'; +const getPreview = (text, projectPath = '') => { + let url; + + if (projectPath) { + url = `/${projectPath}/preview_markdown`; + } else { + const { group, project } = document.body.dataset; + url = `/${group}/${project}/preview_markdown`; + } + return axios + .post(url, { + text, + }) + .then(({ data }) => { + return data.body; + }); +}; + +const setupDomElement = ({ injectToEl = null } = {}) => { + const previewEl = document.createElement('div'); + previewEl.classList.add(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS); + previewEl.style.display = 'none'; + if (injectToEl) { + injectToEl.appendChild(previewEl); + } + return previewEl; +}; + export class EditorMarkdownExtension extends SourceEditorExtension { + constructor({ instance, projectPath, ...args } = {}) { + super({ instance, ...args }); + Object.assign(instance, { + projectPath, + preview: { + el: undefined, + action: undefined, + shown: false, + modelChangeListener: undefined, + }, + }); + this.setupPreviewAction.call(instance); + + instance.getModel().onDidChangeLanguage(({ newLanguage, oldLanguage } = {}) => { + if (newLanguage === 'markdown' && oldLanguage !== newLanguage) { + instance.setupPreviewAction(); + } else { + instance.cleanup(); + } + }); + + instance.onDidChangeModel(() => { + const model = instance.getModel(); + if (model) { + const { language } = model.getLanguageIdentifier(); + instance.cleanup(); + if (language === 'markdown') { + instance.setupPreviewAction(); + } + } + }); + } + + static togglePreviewLayout() { + const { width, height } = this.getLayoutInfo(); + const newWidth = this.preview.shown + ? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH + : width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH; + this.layout({ width: newWidth, height }); + } + + static togglePreviewPanel() { + const parentEl = this.getDomNode().parentElement; + const { el: previewEl } = this.preview; + parentEl.classList.toggle(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS); + + if (previewEl.style.display === 'none') { + // Show the preview panel + this.fetchPreview(); + } else { + // Hide the preview panel + previewEl.style.display = 'none'; + } + } + + cleanup() { + if (this.preview.modelChangeListener) { + this.preview.modelChangeListener.dispose(); + } + this.preview.action.dispose(); + if (this.preview.shown) { + EditorMarkdownExtension.togglePreviewPanel.call(this); + EditorMarkdownExtension.togglePreviewLayout.call(this); + } + this.preview.shown = false; + } + + fetchPreview() { + const { el: previewEl } = this.preview; + getPreview(this.getValue(), this.projectPath) + .then((data) => { + previewEl.innerHTML = sanitize(data); + syntaxHighlight(previewEl.querySelectorAll('.js-syntax-highlight')); + previewEl.style.display = 'block'; + }) + .catch(() => createFlash(BLOB_PREVIEW_ERROR)); + } + + setupPreviewAction() { + if (this.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return; + + this.preview.action = this.addAction({ + id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, + label: __('Preview Markdown'), + keybindings: [ + // eslint-disable-next-line no-bitwise,no-undef + monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_P), + ], + contextMenuGroupId: 'navigation', + contextMenuOrder: 1.5, + + // Method that will be executed when the action is triggered. + // @param ed The editor instance is passed in as a convenience + run(instance) { + instance.togglePreview(); + }, + }); + } + + togglePreview() { + if (!this.preview?.el) { + this.preview.el = setupDomElement({ injectToEl: this.getDomNode().parentElement }); + } + EditorMarkdownExtension.togglePreviewLayout.call(this); + EditorMarkdownExtension.togglePreviewPanel.call(this); + + if (!this.preview?.shown) { + this.preview.modelChangeListener = this.onDidChangeModelContent( + debounce(this.fetchPreview.bind(this), EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY), + ); + } else { + this.preview.modelChangeListener.dispose(); + } + + this.preview.shown = !this.preview?.shown; + } + getSelectedText(selection = this.getSelection()) { const { startLineNumber, endLineNumber, startColumn, endColumn } = selection; const valArray = this.getValue().split('\n'); diff --git a/app/assets/javascripts/editor/source_editor.js b/app/assets/javascripts/editor/source_editor.js index ee97714824e..81ddf8d77fa 100644 --- a/app/assets/javascripts/editor/source_editor.js +++ b/app/assets/javascripts/editor/source_editor.js @@ -1,7 +1,6 @@ -import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor'; +import { editor as monacoEditor, Uri } from 'monaco-editor'; import { defaultEditorOptions } from '~/ide/lib/editor_options'; import languages from '~/ide/lib/languages'; -import { DEFAULT_THEME, themes } from '~/ide/lib/themes'; import { registerLanguages } from '~/ide/utils'; import { joinPaths } from '~/lib/utils/url_utility'; import { uuids } from '~/lib/utils/uuids'; @@ -11,7 +10,7 @@ import { EDITOR_READY_EVENT, EDITOR_TYPE_DIFF, } from './constants'; -import { clearDomElement } from './utils'; +import { clearDomElement, setupEditorTheme, getBlobLanguage } from './utils'; export default class SourceEditor { constructor(options = {}) { @@ -22,26 +21,11 @@ export default class SourceEditor { ...options, }; - SourceEditor.setupMonacoTheme(); + setupEditorTheme(); registerLanguages(...languages); } - static setupMonacoTheme() { - const themeName = window.gon?.user_color_scheme || DEFAULT_THEME; - const theme = themes.find((t) => t.name === themeName); - if (theme) monacoEditor.defineTheme(themeName, theme.data); - monacoEditor.setTheme(theme ? themeName : DEFAULT_THEME); - } - - static getModelLanguage(path) { - const ext = `.${path.split('.').pop()}`; - const language = monacoLanguages - .getLanguages() - .find((lang) => lang.extensions.indexOf(ext) !== -1); - return language ? language.id : 'plaintext'; - } - static pushToImportsArray(arr, toImport) { arr.push(import(toImport)); } @@ -124,10 +108,7 @@ export default class SourceEditor { return model; } const diffModel = { - original: monacoEditor.createModel( - blobOriginalContent, - SourceEditor.getModelLanguage(model.uri.path), - ), + original: monacoEditor.createModel(blobOriginalContent, getBlobLanguage(model.uri.path)), modified: model, }; instance.setModel(diffModel); @@ -155,7 +136,7 @@ export default class SourceEditor { }; static instanceUpdateLanguage(inst, path) { - const lang = SourceEditor.getModelLanguage(path); + const lang = getBlobLanguage(path); const model = inst.getModel(); return monacoEditor.setModelLanguage(model, lang); } diff --git a/app/assets/javascripts/editor/utils.js b/app/assets/javascripts/editor/utils.js index af4473413f4..df9d3f2b9fb 100644 --- a/app/assets/javascripts/editor/utils.js +++ b/app/assets/javascripts/editor/utils.js @@ -1,3 +1,6 @@ +import { editor as monacoEditor, languages as monacoLanguages } from 'monaco-editor'; +import { DEFAULT_THEME, themes } from '~/ide/lib/themes'; + export const clearDomElement = (el) => { if (!el || !el.firstChild) return; @@ -6,6 +9,28 @@ export const clearDomElement = (el) => { } }; -export default () => ({ - clearDomElement, -}); +export const setupEditorTheme = () => { + const themeName = window.gon?.user_color_scheme || DEFAULT_THEME; + const theme = themes.find((t) => t.name === themeName); + if (theme) monacoEditor.defineTheme(themeName, theme.data); + monacoEditor.setTheme(theme ? themeName : DEFAULT_THEME); +}; + +export const getBlobLanguage = (blobPath) => { + const defaultLanguage = 'plaintext'; + + if (!blobPath) { + return defaultLanguage; + } + + const ext = `.${blobPath.split('.').pop()}`; + const language = monacoLanguages + .getLanguages() + .find((lang) => lang.extensions.indexOf(ext) !== -1); + return language ? language.id : defaultLanguage; +}; + +export const setupCodeSnippet = (el) => { + monacoEditor.colorizeElement(el); + setupEditorTheme(); +}; diff --git a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue index 76ad74e04d0..4783b92942c 100644 --- a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue +++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue @@ -1,29 +1,46 @@ <script> -/* eslint-disable vue/no-v-html */ /** * Render modal to confirm rollback/redeploy. */ - -import { GlModal } from '@gitlab/ui'; +import { GlModal, GlSprintf, GlLink } from '@gitlab/ui'; import { escape } from 'lodash'; -import { s__, sprintf } from '~/locale'; +import csrf from '~/lib/utils/csrf'; +import { __, s__, sprintf } from '~/locale'; import eventHub from '../event_hub'; export default { name: 'ConfirmRollbackModal', - components: { GlModal, + GlSprintf, + GlLink, + }, + model: { + prop: 'visible', + event: 'change', }, - props: { environment: { type: Object, required: true, }, + visible: { + type: Boolean, + required: false, + default: false, + }, + hasMultipleCommits: { + type: Boolean, + required: false, + default: true, + }, + retryUrl: { + type: String, + required: false, + default: null, + }, }, - computed: { modalTitle() { const title = this.environment.isLastDeployment @@ -34,58 +51,47 @@ export default { name: escape(this.environment.name), }); }, - commitShortSha() { - const { last_deployment } = this.environment; - return this.commitData(last_deployment, 'short_id'); - }, - - commitUrl() { - const { last_deployment } = this.environment; - return this.commitData(last_deployment, 'commit_path'); - }, + if (this.hasMultipleCommits) { + const { last_deployment } = this.environment; + return this.commitData(last_deployment, 'short_id'); + } - commitTitle() { - const { last_deployment } = this.environment; - return this.commitData(last_deployment, 'title'); + return this.environment.commitShortSha; }, + commitUrl() { + if (this.hasMultipleCommits) { + const { last_deployment } = this.environment; + return this.commitData(last_deployment, 'commit_path'); + } - modalText() { - const linkStart = `<a class="commit-sha mr-0" href="${escape(this.commitUrl)}">`; - const commitId = escape(this.commitShortSha); - const linkEnd = '</a>'; - const name = escape(this.name); - const body = this.environment.isLastDeployment - ? s__( - 'Environments|This action will relaunch the job for commit %{linkStart}%{commitId}%{linkEnd}, putting the environment in a previous version. Are you sure you want to continue?', - ) - : s__( - 'Environments|This action will run the job defined by %{name} for commit %{linkStart}%{commitId}%{linkEnd} putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?', - ); - return sprintf( - body, - { - commitId, - linkStart, - linkEnd, - name, - }, - false, - ); + return this.environment.commitUrl; }, - modalActionText() { return this.environment.isLastDeployment ? s__('Environments|Re-deploy') : s__('Environments|Rollback'); }, - }, + primaryProps() { + let attributes = [{ variant: 'danger' }]; + + if (this.retryUrl) { + attributes = [...attributes, { 'data-method': 'post' }, { href: this.retryUrl }]; + } + return { + text: this.modalActionText, + attributes, + }; + }, + }, methods: { + handleChange(event) { + this.$emit('change', event); + }, onOk() { eventHub.$emit('rollbackEnvironment', this.environment); }, - commitData(lastDeployment, key) { if (lastDeployment && lastDeployment.commit) { return lastDeployment.commit[key]; @@ -94,16 +100,51 @@ export default { return ''; }, }, + csrf, + cancelProps: { + text: __('Cancel'), + attributes: [{ variant: 'danger' }], + }, }; </script> <template> <gl-modal :title="modalTitle" + :visible="visible" + :action-cancel="$options.cancelProps" + :action-primary="primaryProps" modal-id="confirm-rollback-modal" - :ok-title="modalActionText" - ok-variant="danger" @ok="onOk" + @change="handleChange" > - <p v-html="modalText"></p> + <gl-sprintf + v-if="environment.isLastDeployment" + :message=" + s__( + 'Environments|This action will relaunch the job for commit %{linkStart}%{commitId}%{linkEnd}, putting the environment in a previous version. Are you sure you want to continue?', + ) + " + > + <template #link> + <gl-link :href="commitUrl" target="_blank" class="commit-sha mr-0">{{ + commitShortSha + }}</gl-link> + </template> + </gl-sprintf> + <gl-sprintf + v-else + :message=" + s__( + 'Environments|This action will run the job defined by %{name} for commit %{linkStart}%{commitId}%{linkEnd} putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?', + ) + " + > + <template #name>{{ environment.name }}</template> + <template #link> + <gl-link :href="commitUrl" target="_blank" class="commit-sha mr-0">{{ + commitShortSha + }}</gl-link> + </template> + </gl-sprintf> </gl-modal> </template> diff --git a/app/assets/javascripts/environments/components/edit_environment.vue b/app/assets/javascripts/environments/components/edit_environment.vue new file mode 100644 index 00000000000..1cd960d7cd6 --- /dev/null +++ b/app/assets/javascripts/environments/components/edit_environment.vue @@ -0,0 +1,58 @@ +<script> +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { visitUrl } from '~/lib/utils/url_utility'; +import EnvironmentForm from './environment_form.vue'; + +export default { + components: { + EnvironmentForm, + }, + inject: ['projectEnvironmentsPath', 'updateEnvironmentPath'], + props: { + environment: { + required: true, + type: Object, + }, + }, + data() { + return { + formEnvironment: { + name: this.environment.name, + externalUrl: this.environment.external_url, + }, + loading: false, + }; + }, + methods: { + onChange(environment) { + this.formEnvironment = environment; + }, + onSubmit() { + this.loading = true; + axios + .put(this.updateEnvironmentPath, { + id: this.environment.id, + name: this.formEnvironment.name, + external_url: this.formEnvironment.externalUrl, + }) + .then(({ data: { path } }) => visitUrl(path)) + .catch((error) => { + const message = error.response.data.message[0]; + createFlash({ message }); + this.loading = false; + }); + }, + }, +}; +</script> +<template> + <environment-form + :cancel-path="projectEnvironmentsPath" + :environment="formEnvironment" + :title="__('Edit environment')" + :loading="loading" + @change="onChange" + @submit="onSubmit" + /> +</template> diff --git a/app/assets/javascripts/environments/components/enable_review_app_modal.vue b/app/assets/javascripts/environments/components/enable_review_app_modal.vue index b0c0f83b88a..d770a2302e8 100644 --- a/app/assets/javascripts/environments/components/enable_review_app_modal.vue +++ b/app/assets/javascripts/environments/components/enable_review_app_modal.vue @@ -1,5 +1,6 @@ <script> import { GlLink, GlModal, GlSprintf } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; @@ -25,6 +26,9 @@ export default { step3: s__( `EnableReviewApp|%{stepStart}Step 3%{stepEnd}. Add it to the project %{linkStart}gitlab-ci.yml%{linkEnd} file.`, ), + step4: s__( + `EnableReviewApp|%{stepStart}Step 4 (optional)%{stepEnd}. Enable Visual Reviews by following the %{linkStart}setup instructions%{linkEnd}.`, + ), }, modalInfo: { closeText: s__('EnableReviewApp|Close'), @@ -45,6 +49,9 @@ export default { except: - ${this.defaultBranchName}`; }, + visualReviewsDocs() { + return helpPagePath('ci/review_apps/index.md', { anchor: 'visual-reviews' }); + }, }, }; </script> @@ -103,5 +110,15 @@ export default { </template> </gl-sprintf> </p> + <p> + <gl-sprintf :message="$options.instructionText.step4"> + <template #step="{ content }"> + <strong>{{ content }}</strong> + </template> + <template #link="{ content }"> + <gl-link :href="visualReviewsDocs" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> </gl-modal> </template> diff --git a/app/assets/javascripts/environments/components/environment_form.vue b/app/assets/javascripts/environments/components/environment_form.vue new file mode 100644 index 00000000000..6db8fe24e72 --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_form.vue @@ -0,0 +1,146 @@ +<script> +import { GlButton, GlForm, GlFormGroup, GlFormInput, GlLink, GlSprintf } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { isAbsolute } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; + +export default { + components: { + GlButton, + GlForm, + GlFormGroup, + GlFormInput, + GlLink, + GlSprintf, + }, + props: { + environment: { + required: true, + type: Object, + }, + title: { + required: true, + type: String, + }, + cancelPath: { + required: true, + type: String, + }, + loading: { + required: false, + type: Boolean, + default: false, + }, + }, + i18n: { + header: __('Environments'), + helpMessage: __( + 'Environments allow you to track deployments of your application. %{linkStart}More information%{linkEnd}.', + ), + nameLabel: __('Name'), + nameFeedback: __('This field is required'), + urlLabel: __('External URL'), + urlFeedback: __('The URL should start with http:// or https://'), + save: __('Save'), + cancel: __('Cancel'), + }, + helpPagePath: helpPagePath('ci/environments/index.md'), + data() { + return { + visited: { + name: null, + url: null, + }, + }; + }, + computed: { + valid() { + return { + name: this.visited.name && this.environment.name !== '', + url: this.visited.url && isAbsolute(this.environment.externalUrl), + }; + }, + }, + methods: { + onChange(env) { + this.$emit('change', env); + }, + visit(field) { + this.visited[field] = true; + }, + }, +}; +</script> +<template> + <div> + <h3 class="page-title"> + {{ title }} + </h3> + <hr /> + <div class="row gl-mt-3 gl-mb-3"> + <div class="col-lg-3"> + <h4 class="gl-mt-0"> + {{ $options.i18n.header }} + </h4> + <p> + <gl-sprintf :message="$options.i18n.helpMessage"> + <template #link="{ content }"> + <gl-link :href="$options.helpPagePath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </div> + <gl-form + id="new_environment" + :aria-label="title" + class="col-lg-9" + @submit.prevent="$emit('submit')" + > + <gl-form-group + :label="$options.i18n.nameLabel" + label-for="environment_name" + :state="valid.name" + :invalid-feedback="$options.i18n.nameFeedback" + > + <gl-form-input + id="environment_name" + :value="environment.name" + :state="valid.name" + name="environment[name]" + required + @input="onChange({ ...environment, name: $event })" + @blur="visit('name')" + /> + </gl-form-group> + <gl-form-group + :label="$options.i18n.urlLabel" + :state="valid.url" + :invalid-feedback="$options.i18n.urlFeedback" + label-for="environment_external_url" + > + <gl-form-input + id="environment_external_url" + :value="environment.externalUrl" + :state="valid.url" + name="environment[external_url]" + type="url" + @input="onChange({ ...environment, externalUrl: $event })" + @blur="visit('url')" + /> + </gl-form-group> + + <div class="form-actions"> + <gl-button + :loading="loading" + type="submit" + variant="confirm" + name="commit" + class="js-no-auto-disable" + >{{ $options.i18n.save }}</gl-button + > + <gl-button :href="cancelPath">{{ $options.i18n.cancel }}</gl-button> + </div> + </gl-form> + </div> + </div> +</template> diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 5ae8b000fc0..897f6ce393e 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -776,23 +776,39 @@ export default { role="gridcell" > <div class="btn-group table-action-buttons" role="group"> - <pin-component v-if="canShowAutoStopDate" :auto-stop-url="autoStopUrl" /> + <pin-component + v-if="canShowAutoStopDate" + :auto-stop-url="autoStopUrl" + data-track-action="click_button" + data-track-label="environment_pin" + /> <external-url-component v-if="externalURL && canReadEnvironment" :external-url="externalURL" + data-track-action="click_button" + data-track-label="environment_url" /> <monitoring-button-component v-if="monitoringUrl && canReadEnvironment" :monitoring-url="monitoringUrl" + data-track-action="click_button" + data-track-label="environment_monitoring" /> - <actions-component v-if="actions.length > 0" :actions="actions" /> + <actions-component + v-if="actions.length > 0" + :actions="actions" + data-track-action="click_dropdown" + data-track-label="environment_actions" + /> <terminal-button-component v-if="model && model.terminal_path" :terminal-path="model.terminal_path" + data-track-action="click_button" + data-track-label="environment_terminal" /> <rollback-component @@ -800,11 +816,23 @@ export default { :environment="model" :is-last-deployment="isLastDeployment" :retry-url="retryUrl" + data-track-action="click_button" + data-track-label="environment_rollback" /> - <stop-component v-if="canStopEnvironment" :environment="model" /> + <stop-component + v-if="canStopEnvironment" + :environment="model" + data-track-action="click_button" + data-track-label="environment_stop" + /> - <delete-component v-if="canDeleteEnvironment" :environment="model" /> + <delete-component + v-if="canDeleteEnvironment" + :environment="model" + data-track-action="click_button" + data-track-label="environment_delete" + /> </div> </div> </div> diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index e4cf5760987..105315dcf51 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -1,7 +1,9 @@ <script> -import { GlBadge, GlButton, GlModalDirective, GlTab, GlTabs } from '@gitlab/ui'; +import { GlBadge, GlButton, GlModalDirective, GlTab, GlTabs, GlAlert } from '@gitlab/ui'; import createFlash from '~/flash'; +import { setCookie, getCookie, parseBoolean } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; +import { ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME } from '../constants'; import eventHub from '../event_hub'; import environmentsMixin from '../mixins/environments_mixin'; import EnvironmentsPaginationApiMixin from '../mixins/environments_pagination_api_mixin'; @@ -15,6 +17,12 @@ export default { i18n: { newEnvironmentButtonLabel: s__('Environments|New environment'), reviewAppButtonLabel: s__('Environments|Enable review app'), + surveyAlertTitle: s__('Environments|Help us improve environments'), + surveyAlertText: s__( + 'Environments|Your feedback helps GitLab make environments better for you and other users. Participate and enter a sweepstake to win a USD 30 gift card.', + ), + surveyAlertButtonLabel: s__('Environments|Take the survey'), + surveyDismissButtonLabel: s__('Environments|Dismiss'), }, modal: { id: 'enable-review-app-info', @@ -25,6 +33,7 @@ export default { EnableReviewAppModal, GlBadge, GlButton, + GlAlert, GlTab, GlTabs, StopEnvironmentModal, @@ -56,6 +65,13 @@ export default { required: true, }, }, + data() { + return { + environmentsSurveyAlertDismissed: parseBoolean( + getCookie(ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME), + ), + }; + }, created() { eventHub.$on('toggleFolder', this.toggleFolder); @@ -105,6 +121,11 @@ export default { openFolders.forEach((folder) => this.fetchChildEnvironments(folder)); } }, + + onSurveyAlertDismiss() { + setCookie(ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME, 'true'); + this.environmentsSurveyAlertDismissed = true; + }, }, }; </script> @@ -135,6 +156,19 @@ export default { >{{ $options.i18n.newEnvironmentButtonLabel }}</gl-button > </div> + <gl-alert + v-if="!environmentsSurveyAlertDismissed" + class="gl-my-4" + :title="$options.i18n.surveyAlertTitle" + :primary-button-text="$options.i18n.surveyAlertButtonLabel" + variant="info" + dismissible + :dismiss-label="$options.i18n.surveyDismissButtonLabel" + primary-button-link="https://gitlab.fra1.qualtrics.com/jfe/form/SV_a2xyFsAA4D0w0Jg" + @dismiss="onSurveyAlertDismiss" + > + {{ $options.i18n.surveyAlertText }} + </gl-alert> <gl-tabs :value="activeTab" content-class="gl-display-none"> <gl-tab v-for="(tab, idx) in tabs" diff --git a/app/assets/javascripts/environments/components/environments_detail_header.vue b/app/assets/javascripts/environments/components/environments_detail_header.vue new file mode 100644 index 00000000000..467c89fd8b8 --- /dev/null +++ b/app/assets/javascripts/environments/components/environments_detail_header.vue @@ -0,0 +1,174 @@ +<script> +import { GlButton, GlModalDirective, GlTooltipDirective as GlTooltip, GlSprintf } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { __, s__ } from '~/locale'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import DeleteEnvironmentModal from './delete_environment_modal.vue'; +import StopEnvironmentModal from './stop_environment_modal.vue'; + +export default { + name: 'EnvironmentsDetailHeader', + csrf, + components: { + GlButton, + GlSprintf, + TimeAgo, + DeleteEnvironmentModal, + StopEnvironmentModal, + }, + directives: { + GlModalDirective, + GlTooltip, + }, + mixins: [timeagoMixin], + props: { + environment: { + type: Object, + required: true, + }, + canReadEnvironment: { + type: Boolean, + required: true, + }, + canAdminEnvironment: { + type: Boolean, + required: true, + }, + canUpdateEnvironment: { + type: Boolean, + required: true, + }, + canDestroyEnvironment: { + type: Boolean, + required: true, + }, + canStopEnvironment: { + type: Boolean, + required: true, + }, + cancelAutoStopPath: { + type: String, + required: false, + default: '', + }, + metricsPath: { + type: String, + required: false, + default: '', + }, + updatePath: { + type: String, + required: false, + default: '', + }, + terminalPath: { + type: String, + required: false, + default: '', + }, + }, + i18n: { + autoStopAtText: s__('Environments|Auto stops %{autoStopAt}'), + metricsButtonTitle: __('See metrics'), + metricsButtonText: __('Monitoring'), + editButtonText: __('Edit'), + stopButtonText: s__('Environments|Stop'), + deleteButtonText: s__('Environments|Delete'), + externalButtonTitle: s__('Environments|Open live environment'), + externalButtonText: __('View deployment'), + cancelAutoStopButtonTitle: __('Prevent environment from auto-stopping'), + }, + computed: { + shouldShowCancelAutoStopButton() { + return this.environment.isAvailable && Boolean(this.environment.autoStopAt); + }, + shouldShowExternalUrlButton() { + return this.canReadEnvironment && Boolean(this.environment.externalUrl); + }, + shouldShowStopButton() { + return this.canStopEnvironment && this.environment.isAvailable; + }, + shouldShowTerminalButton() { + return this.canAdminEnvironment && this.environment.hasTerminals; + }, + }, +}; +</script> +<template> + <header class="top-area gl-justify-content-between"> + <div class="gl-display-flex gl-flex-grow-1 gl-align-items-center"> + <h3 class="page-title"> + {{ environment.name }} + </h3> + <p v-if="shouldShowCancelAutoStopButton" class="gl-mb-0 gl-ml-3" data-testid="auto-stops-at"> + <gl-sprintf :message="$options.i18n.autoStopAtText"> + <template #autoStopAt> + <time-ago :time="environment.autoStopAt" /> + </template> + </gl-sprintf> + </p> + </div> + <div class="nav-controls gl-my-1"> + <form method="POST" :action="cancelAutoStopPath" data-testid="cancel-auto-stop-form"> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + <gl-button + v-if="shouldShowCancelAutoStopButton" + v-gl-tooltip.hover + data-testid="cancel-auto-stop-button" + :title="$options.i18n.cancelAutoStopButtonTitle" + type="submit" + icon="thumbtack" + /> + </form> + <gl-button + v-if="shouldShowTerminalButton" + data-testid="terminal-button" + :href="terminalPath" + icon="terminal" + /> + <gl-button + v-if="shouldShowExternalUrlButton" + v-gl-tooltip.hover + data-testid="external-url-button" + :title="$options.i18n.externalButtonTitle" + :href="environment.externalUrl" + icon="external-link" + target="_blank" + >{{ $options.i18n.externalButtonText }}</gl-button + > + <gl-button + v-if="canReadEnvironment" + data-testid="metrics-button" + :href="metricsPath" + :title="$options.i18n.metricsButtonTitle" + icon="chart" + class="gl-mr-2" + > + {{ $options.i18n.metricsButtonText }} + </gl-button> + <gl-button v-if="canUpdateEnvironment" data-testid="edit-button" :href="updatePath"> + {{ $options.i18n.editButtonText }} + </gl-button> + <gl-button + v-if="shouldShowStopButton" + v-gl-modal-directive="'stop-environment-modal'" + data-testid="stop-button" + icon="stop" + variant="danger" + > + {{ $options.i18n.stopButtonText }} + </gl-button> + <gl-button + v-if="canDestroyEnvironment" + v-gl-modal-directive="'delete-environment-modal'" + data-testid="destroy-button" + variant="danger" + > + {{ $options.i18n.deleteButtonText }} + </gl-button> + </div> + <delete-environment-modal v-if="canDestroyEnvironment" :environment="environment" /> + <stop-environment-modal v-if="shouldShowStopButton" :environment="environment" /> + </header> +</template> diff --git a/app/assets/javascripts/environments/components/new_environment.vue b/app/assets/javascripts/environments/components/new_environment.vue new file mode 100644 index 00000000000..14da2668417 --- /dev/null +++ b/app/assets/javascripts/environments/components/new_environment.vue @@ -0,0 +1,51 @@ +<script> +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { visitUrl } from '~/lib/utils/url_utility'; +import EnvironmentForm from './environment_form.vue'; + +export default { + components: { + EnvironmentForm, + }, + inject: ['projectEnvironmentsPath'], + data() { + return { + environment: { + name: '', + externalUrl: '', + }, + loading: false, + }; + }, + methods: { + onChange(env) { + this.environment = env; + }, + onSubmit() { + this.loading = true; + axios + .post(this.projectEnvironmentsPath, { + name: this.environment.name, + external_url: this.environment.externalUrl, + }) + .then(({ data: { path } }) => visitUrl(path)) + .catch((error) => { + const message = error.response.data.message[0]; + createFlash({ message }); + this.loading = false; + }); + }, + }, +}; +</script> +<template> + <environment-form + :cancel-path="projectEnvironmentsPath" + :environment="environment" + :title="__('New environment')" + :loading="loading" + @change="onChange($event)" + @submit="onSubmit" + /> +</template> diff --git a/app/assets/javascripts/environments/components/rollback_modal_manager.vue b/app/assets/javascripts/environments/components/rollback_modal_manager.vue new file mode 100644 index 00000000000..6aa7d96fdfd --- /dev/null +++ b/app/assets/javascripts/environments/components/rollback_modal_manager.vue @@ -0,0 +1,57 @@ +<script> +import { parseBoolean } from '~/lib/utils/common_utils'; +import ConfirmRollbackModal from './confirm_rollback_modal.vue'; + +export default { + components: { + ConfirmRollbackModal, + }, + props: { + selector: { + type: String, + required: true, + }, + }, + data() { + return { + environment: null, + retryPath: '', + visible: false, + }; + }, + mounted() { + document.querySelectorAll(this.selector).forEach((button) => { + button.addEventListener('click', (e) => { + e.preventDefault(); + const { + environmentName, + commitShortSha, + commitUrl, + isLastDeployment, + retryPath, + } = button.dataset; + + this.environment = { + name: environmentName, + commitShortSha, + commitUrl, + isLastDeployment: parseBoolean(isLastDeployment), + }; + this.retryPath = retryPath; + this.visible = true; + }); + }); + }, +}; +</script> + +<template> + <confirm-rollback-modal + v-if="environment" + v-model="visible" + :environment="environment" + :has-multiple-commits="false" + :retry-url="retryPath" + /> + <div v-else></div> +</template> diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js index 6d427bef4e6..a02e72dfa72 100644 --- a/app/assets/javascripts/environments/constants.js +++ b/app/assets/javascripts/environments/constants.js @@ -38,3 +38,5 @@ export const CANARY_STATUS = { }; export const CANARY_UPDATE_MODAL = 'confirm-canary-change'; + +export const ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME = 'environments_survey_alert_dismissed'; diff --git a/app/assets/javascripts/environments/edit.js b/app/assets/javascripts/environments/edit.js new file mode 100644 index 00000000000..dd6680f64bd --- /dev/null +++ b/app/assets/javascripts/environments/edit.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import EditEnvironment from './components/edit_environment.vue'; + +export default (el) => + new Vue({ + el, + provide: { + projectEnvironmentsPath: el.dataset.projectEnvironmentsPath, + updateEnvironmentPath: el.dataset.updateEnvironmentPath, + }, + render(h) { + return h(EditEnvironment, { + props: { + environment: JSON.parse(el.dataset.environment), + }, + }); + }, + }); diff --git a/app/assets/javascripts/environments/init_confirm_rollback_modal.js b/app/assets/javascripts/environments/init_confirm_rollback_modal.js new file mode 100644 index 00000000000..0161bb6078f --- /dev/null +++ b/app/assets/javascripts/environments/init_confirm_rollback_modal.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import RollbackModalManager from './components/rollback_modal_manager.vue'; + +const mountConfirmRollbackModal = (optionalProps) => + new Vue({ + render(h) { + return h(RollbackModalManager, { + props: { + selector: '.js-confirm-rollback-modal-button', + ...optionalProps, + }, + }); + }, + }).$mount(); + +export default (optionalProps = {}) => mountConfirmRollbackModal(optionalProps); diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js index 6f701f87261..85cff73cc3e 100644 --- a/app/assets/javascripts/environments/mixins/environments_mixin.js +++ b/app/assets/javascripts/environments/mixins/environments_mixin.js @@ -108,7 +108,19 @@ export default { this.service .postAction(endpoint) - .then(() => this.fetchEnvironments()) + .then(() => { + // Originally, the detail page buttons were implemented as <form>s that POSTed + // to the server, which would naturally result in a page refresh. + // When environment details page was converted to Vue, the buttons were updated to trigger + // HTTP requests using `axios`, which did not cause a refresh on completion. + // To preserve the original behavior, we manually reload the page when + // network requests complete successfully. + if (!this.isDetailView) { + this.fetchEnvironments(); + } else { + window.location.reload(); + } + }) .catch((err) => { this.isLoading = false; createFlash({ diff --git a/app/assets/javascripts/environments/mount_show.js b/app/assets/javascripts/environments/mount_show.js index d0b68b0c14f..f1c2dfec94b 100644 --- a/app/assets/javascripts/environments/mount_show.js +++ b/app/assets/javascripts/environments/mount_show.js @@ -1,30 +1,48 @@ import Vue from 'vue'; -import DeleteEnvironmentModal from './components/delete_environment_modal.vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import EnvironmentsDetailHeader from './components/environments_detail_header.vue'; import environmentsMixin from './mixins/environments_mixin'; -export default () => { - const el = document.getElementById('delete-environment-modal'); +export const initHeader = () => { + const el = document.getElementById('environments-detail-view-header'); const container = document.getElementById('environments-detail-view'); + const dataset = convertObjectPropsToCamelCase(JSON.parse(container.dataset.details)); return new Vue({ el, - components: { - DeleteEnvironmentModal, - }, mixins: [environmentsMixin], data() { - const environment = JSON.parse(JSON.stringify(container.dataset)); - environment.delete_path = environment.deletePath; - environment.onSingleEnvironmentPage = true; + const environment = { + name: dataset.name, + id: Number(dataset.id), + externalUrl: dataset.externalUrl, + isAvailable: dataset.isEnvironmentAvailable, + hasTerminals: dataset.hasTerminals, + autoStopAt: dataset.autoStopAt, + onSingleEnvironmentPage: true, + // TODO: These two props are snake_case because the environments_mixin file uses + // them and the mixin is imported in several files. It would be nice to conver them to camelCase. + stop_path: dataset.environmentStopPath, + delete_path: dataset.environmentDeletePath, + }; return { environment, }; }, render(createElement) { - return createElement('delete-environment-modal', { + return createElement(EnvironmentsDetailHeader, { props: { environment: this.environment, + canDestroyEnvironment: dataset.canDestroyEnvironment, + canUpdateEnvironment: dataset.canUpdateEnvironment, + canReadEnvironment: dataset.canReadEnvironment, + canStopEnvironment: dataset.canStopEnvironment, + canAdminEnvironment: dataset.canAdminEnvironment, + cancelAutoStopPath: dataset.environmentCancelAutoStopPath, + terminalPath: dataset.environmentTerminalPath, + metricsPath: dataset.environmentMetricsPath, + updatePath: dataset.environmentEditPath, }, }); }, diff --git a/app/assets/javascripts/environments/new.js b/app/assets/javascripts/environments/new.js new file mode 100644 index 00000000000..76aaf809d17 --- /dev/null +++ b/app/assets/javascripts/environments/new.js @@ -0,0 +1,11 @@ +import Vue from 'vue'; +import NewEnvironment from './components/new_environment.vue'; + +export default (el) => + new Vue({ + el, + provide: { projectEnvironmentsPath: el.dataset.projectEnvironmentsPath }, + render(h) { + return h(NewEnvironment); + }, + }); diff --git a/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue index 4daf8b4e6bf..858c30649bb 100644 --- a/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue +++ b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue @@ -25,19 +25,19 @@ export default { }, stickinessOptions: [ { - value: 'DEFAULT', + value: 'default', text: __('Available ID'), }, { - value: 'USERID', + value: 'userId', text: __('User ID'), }, { - value: 'SESSIONID', + value: 'sessionId', text: __('Session ID'), }, { - value: 'RANDOM', + value: 'random', text: __('Random'), }, ], diff --git a/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js deleted file mode 100644 index 42d0fbacca0..00000000000 --- a/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js +++ /dev/null @@ -1,36 +0,0 @@ -import { __ } from '~/locale'; -import FilteredSearchTokenKeys from './filtered_search_token_keys'; - -const tokenKeys = [ - { - formattedKey: __('Status'), - key: 'status', - type: 'string', - param: 'status', - symbol: '', - icon: 'messages', - tag: 'status', - }, - { - formattedKey: __('Type'), - key: 'type', - type: 'string', - param: 'type', - symbol: '', - icon: 'cube', - tag: 'type', - }, - { - formattedKey: __('Tag'), - key: 'tag', - type: 'array', - param: 'name[]', - symbol: '~', - icon: 'tag', - tag: '~tag', - }, -]; - -const AdminRunnersFilteredSearchTokenKeys = new FilteredSearchTokenKeys(tokenKeys); - -export default AdminRunnersFilteredSearchTokenKeys; diff --git a/app/assets/javascripts/frequent_items/index.js b/app/assets/javascripts/frequent_items/index.js deleted file mode 100644 index 9de18ba092f..00000000000 --- a/app/assets/javascripts/frequent_items/index.js +++ /dev/null @@ -1,77 +0,0 @@ -import $ from 'jquery'; -import Vue from 'vue'; -import Vuex from 'vuex'; -import { createStore } from '~/frequent_items/store'; -import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue'; -import Translate from '~/vue_shared/translate'; -import { FREQUENT_ITEMS_DROPDOWNS } from './constants'; -import eventHub from './event_hub'; - -Vue.use(Vuex); -Vue.use(Translate); - -export default function initFrequentItemDropdowns() { - const store = createStore(); - - FREQUENT_ITEMS_DROPDOWNS.forEach((dropdown) => { - const { namespace, key, vuexModule } = dropdown; - const el = document.getElementById(`js-${namespace}-dropdown`); - const navEl = document.getElementById(`nav-${namespace}-dropdown`); - - // Don't do anything if element doesn't exist (No groups dropdown) - // This is for when the user accesses GitLab without logging in - if (!el || !navEl) { - return; - } - - import('./components/app.vue') - .then(({ default: FrequentItems }) => { - // eslint-disable-next-line no-new - new Vue({ - el, - store, - data() { - const { dataset } = this.$options.el; - const item = { - id: Number(dataset[`${key}Id`]), - name: dataset[`${key}Name`], - namespace: dataset[`${key}Namespace`], - webUrl: dataset[`${key}WebUrl`], - avatarUrl: dataset[`${key}AvatarUrl`] || null, - lastAccessedOn: Date.now(), - }; - - return { - currentUserName: dataset.userName, - currentItem: item, - }; - }, - render(createElement) { - return createElement( - VuexModuleProvider, - { - props: { - vuexModule, - }, - }, - [ - createElement(FrequentItems, { - props: { - namespace, - currentUserName: this.currentUserName, - currentItem: this.currentItem, - searchClass: 'gl-display-none gl-sm-display-block', - }, - }), - ], - ); - }, - }); - }) - .catch(() => {}); - - $(navEl).on('shown.bs.dropdown', () => { - eventHub.$emit(`${namespace}-dropdownOpen`); - }); - }); -} diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js index aad7712a9f0..312dd0c88dd 100644 --- a/app/assets/javascripts/graphql_shared/constants.js +++ b/app/assets/javascripts/graphql_shared/constants.js @@ -1,10 +1,12 @@ export const TYPE_CI_RUNNER = 'Ci::Runner'; +export const TYPE_EPIC = 'Epic'; export const TYPE_GROUP = 'Group'; export const TYPE_ISSUE = 'Issue'; export const TYPE_ITERATION = 'Iteration'; export const TYPE_ITERATIONS_CADENCE = 'Iterations::Cadence'; export const TYPE_MERGE_REQUEST = 'MergeRequest'; export const TYPE_MILESTONE = 'Milestone'; +export const TYPE_PROJECT = 'Project'; export const TYPE_SCANNER_PROFILE = 'DastScannerProfile'; export const TYPE_SITE_PROFILE = 'DastSiteProfile'; export const TYPE_USER = 'User'; diff --git a/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql index 6ed3be84cd8..3551394ff97 100644 --- a/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql @@ -7,4 +7,5 @@ fragment TimelogFragment on Timelog { note { body } + summary } diff --git a/app/assets/javascripts/graphql_shared/mutations/create_merge_request.mutation.graphql b/app/assets/javascripts/graphql_shared/mutations/create_merge_request.mutation.graphql new file mode 100644 index 00000000000..79c56448b3f --- /dev/null +++ b/app/assets/javascripts/graphql_shared/mutations/create_merge_request.mutation.graphql @@ -0,0 +1,8 @@ +mutation createMergeRequest($input: MergeRequestCreateInput!) { + mergeRequestCreate(input: $input) { + mergeRequest { + iid + } + errors + } +} diff --git a/app/assets/javascripts/graphql_shared/queries/get_users_projects.query.graphql b/app/assets/javascripts/graphql_shared/queries/get_users_projects.query.graphql new file mode 100644 index 00000000000..58b7b4c898d --- /dev/null +++ b/app/assets/javascripts/graphql_shared/queries/get_users_projects.query.graphql @@ -0,0 +1,28 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" + +query getProjects( + $search: String! + $after: String = "" + $first: Int! + $searchNamespaces: Boolean = false + $sort: String + $membership: Boolean = true +) { + projects( + search: $search + after: $after + first: $first + membership: $membership + searchNamespaces: $searchNamespaces + sort: $sort + ) { + nodes { + id + name + nameWithNamespace + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql new file mode 100644 index 00000000000..e345fe97281 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql @@ -0,0 +1,15 @@ +#import "../fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" + +query usersSearch($search: String!, $fullPath: ID!) { + workspace: group(fullPath: $fullPath) { + users: groupMembers(search: $search, relations: [DIRECT, INHERITED]) { + nodes { + user { + ...User + ...UserAvailability + } + } + } + } +} diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js index 18f9a50bbce..828ddd95ffc 100644 --- a/app/assets/javascripts/graphql_shared/utils.js +++ b/app/assets/javascripts/graphql_shared/utils.js @@ -2,6 +2,21 @@ import { isArray } from 'lodash'; /** * Ids generated by GraphQL endpoints are usually in the format + * gid://gitlab/Environments/123. This method checks if the passed id follows that format + * + * @param {String|Number} id The id value + * @returns {Boolean} + */ +export const isGid = (id) => { + if (typeof id === 'string' && id.startsWith('gid://gitlab/')) { + return true; + } + + return false; +}; + +/** + * Ids generated by GraphQL endpoints are usually in the format * gid://gitlab/Environments/123. This method extracts Id number * from the Id path * @@ -35,6 +50,10 @@ export const convertToGraphQLId = (type, id) => { throw new TypeError(`id must be a number or string; got ${typeof id}`); } + if (isGid(id)) { + return id; + } + return `gid://gitlab/${type}/${id}`; }; diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index ad0b27c9693..10c45abbfa2 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -28,6 +28,10 @@ export default { GlLoadingIcon, GlIcon, UserAccessRoleBadge, + ComplianceFrameworkLabel: () => + import( + 'ee_component/vue_shared/components/compliance_framework_label/compliance_framework_label.vue' + ), itemCaret, itemTypeIcon, itemStats, @@ -67,6 +71,9 @@ export default { hasAvatar() { return this.group.avatarUrl !== null; }, + hasComplianceFramework() { + return Boolean(this.group.complianceFramework?.name); + }, isGroup() { return this.group.type === 'group'; }, @@ -82,6 +89,9 @@ export default { microdata() { return this.group.microdata || {}; }, + complianceFramework() { + return this.group.complianceFramework; + }, }, methods: { onClickRowGroup(e) { @@ -167,6 +177,13 @@ export default { <user-access-role-badge v-if="group.permission" class="gl-mt-3"> {{ group.permission }} </user-access-role-badge> + <compliance-framework-label + v-if="hasComplianceFramework" + class="gl-mt-3" + :name="complianceFramework.name" + :color="complianceFramework.color" + :description="complianceFramework.description" + /> </div> <div v-if="group.description" class="description"> <span diff --git a/app/assets/javascripts/groups/store/groups_store.js b/app/assets/javascripts/groups/store/groups_store.js index 6cf70f4052e..93fbd8be47d 100644 --- a/app/assets/javascripts/groups/store/groups_store.js +++ b/app/assets/javascripts/groups/store/groups_store.js @@ -1,4 +1,5 @@ -import { normalizeHeaders, parseIntPagination } from '../../lib/utils/common_utils'; +import { isEmpty } from 'lodash'; +import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; import { getGroupItemMicrodata } from './utils'; export default class GroupsStore { @@ -70,7 +71,7 @@ export default class GroupsStore { ? rawGroupItem.subgroup_count : rawGroupItem.children_count; - return { + const groupItem = { id: rawGroupItem.id, name: rawGroupItem.name, fullName: rawGroupItem.full_name, @@ -98,6 +99,16 @@ export default class GroupsStore { pendingRemoval: rawGroupItem.marked_for_deletion, microdata: this.showSchemaMarkup ? getGroupItemMicrodata(rawGroupItem) : {}, }; + + if (!isEmpty(rawGroupItem.compliance_management_framework)) { + groupItem.complianceFramework = { + name: rawGroupItem.compliance_management_framework.name, + color: rawGroupItem.compliance_management_framework.color, + description: rawGroupItem.compliance_management_framework.description, + }; + } + + return groupItem; } removeGroup(group, parentGroup) { diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index 2897f4cbf77..9ec4a07a3d0 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -186,7 +186,7 @@ export default { data-testid="commit-button" class="qa-commit-button" category="primary" - variant="success" + variant="confirm" @click="commit" > {{ __('Commit') }} diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue index fbe353fc4ba..829686ef051 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -1,12 +1,12 @@ <script> -import { GlModal, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlModal, GlTooltipDirective } from '@gitlab/ui'; import { mapActions } from 'vuex'; import { __, sprintf } from '~/locale'; import ListItem from './list_item.vue'; export default { components: { - GlIcon, + GlButton, ListItem, GlModal, }, @@ -70,7 +70,7 @@ export default { <div class="d-flex align-items-center flex-fill"> <strong> {{ titleText }} </strong> <div class="d-flex ml-auto"> - <button + <gl-button v-if="!stagedList" v-gl-tooltip :title="__('Discard all changes')" @@ -79,15 +79,14 @@ export default { :class="{ 'disabled-content': !filesLength, }" - type="button" - class="d-flex ide-staged-action-btn p-0 border-0 align-items-center" + class="gl-shadow-none!" + category="tertiary" + icon="remove-all" data-placement="bottom" data-container="body" data-boundary="viewport" @click="openDiscardModal" - > - <gl-icon :size="16" name="remove-all" class="ml-auto mr-auto position-top-0" /> - </button> + /> </div> </div> </header> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 5c711313ff6..bf5ec849bc5 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -38,6 +38,8 @@ import { getPathParent, readFileAsDataURL, registerSchema, isTextFile } from '.. import FileAlert from './file_alert.vue'; import FileTemplatesBar from './file_templates/bar.vue'; +const MARKDOWN_FILE_TYPE = 'markdown'; + export default { name: 'RepoEditor', components: { @@ -201,7 +203,7 @@ export default { showContentViewer(val) { if (!val) return; - if (this.fileType === 'markdown') { + if (this.fileType === MARKDOWN_FILE_TYPE) { const { content, images } = extractMarkdownImagesFromEntries(this.file, this.entries); this.content = content; this.images = images; @@ -309,6 +311,23 @@ export default { }), ); + if (this.fileType === MARKDOWN_FILE_TYPE) { + import('~/editor/extensions/source_editor_markdown_ext') + .then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => { + this.editor.use( + new MarkdownExtension({ + instance: this.editor, + projectPath: this.currentProjectId, + }), + ); + }) + .catch((e) => + createFlash({ + message: e, + }), + ); + } + this.$nextTick(() => { this.setupEditor(); }); @@ -406,7 +425,11 @@ export default { const reImage = /^image\/(png|jpg|jpeg|gif)$/; const file = event.clipboardData.files[0]; - if (editor.hasTextFocus() && this.fileType === 'markdown' && reImage.test(file?.type)) { + if ( + editor.hasTextFocus() && + this.fileType === MARKDOWN_FILE_TYPE && + reImage.test(file?.type) + ) { // don't let the event be passed on to Monaco. event.preventDefault(); event.stopImmediatePropagation(); diff --git a/app/assets/javascripts/import_entities/components/group_dropdown.vue b/app/assets/javascripts/import_entities/components/group_dropdown.vue index 44d6d17232f..5ba910746ca 100644 --- a/app/assets/javascripts/import_entities/components/group_dropdown.vue +++ b/app/assets/javascripts/import_entities/components/group_dropdown.vue @@ -28,7 +28,7 @@ export default { <template> <gl-dropdown toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" - class="import-entities-namespace-dropdown gl-h-7 gl-flex-fill-1" + class="gl-h-7 gl-flex-fill-1" data-qa-selector="target_namespace_selector_dropdown" v-bind="$attrs" > diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue index cb7e3ef9632..db44be2bcd7 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue @@ -10,20 +10,25 @@ import { GlSearchBoxByClick, GlSprintf, GlSafeHtmlDirective as SafeHtml, - GlTooltip, + GlTable, + GlFormCheckbox, } from '@gitlab/ui'; import { s__, __, n__ } from '~/locale'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; +import ImportStatus from '../../components/import_status.vue'; import { STATUSES } from '../../constants'; import importGroupsMutation from '../graphql/mutations/import_groups.mutation.graphql'; -import setNewNameMutation from '../graphql/mutations/set_new_name.mutation.graphql'; -import setTargetNamespaceMutation from '../graphql/mutations/set_target_namespace.mutation.graphql'; +import setImportTargetMutation from '../graphql/mutations/set_import_target.mutation.graphql'; import availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql'; import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql'; -import ImportTableRow from './import_table_row.vue'; +import { isInvalid } from '../utils'; +import ImportTargetCell from './import_target_cell.vue'; const PAGE_SIZES = [20, 50, 100]; const DEFAULT_PAGE_SIZE = PAGE_SIZES[0]; +const DEFAULT_TH_CLASSES = + 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-200! gl-border-b-1! gl-p-5!'; +const DEFAULT_TD_CLASSES = 'gl-vertical-align-top!'; export default { components: { @@ -35,9 +40,11 @@ export default { GlLink, GlLoadingIcon, GlSearchBoxByClick, + GlFormCheckbox, GlSprintf, - GlTooltip, - ImportTableRow, + GlTable, + ImportStatus, + ImportTargetCell, PaginationLinks, }, directives: { @@ -53,6 +60,10 @@ export default { type: RegExp, required: true, }, + groupUrlErrorMessage: { + type: String, + required: true, + }, }, data() { @@ -60,6 +71,7 @@ export default { filter: '', page: 1, perPage: DEFAULT_PAGE_SIZE, + selectedGroups: [], }; }, @@ -73,21 +85,58 @@ export default { availableNamespaces: availableNamespacesQuery, }, + fields: [ + { + key: 'selected', + label: '', + // eslint-disable-next-line @gitlab/require-i18n-strings + thClass: `${DEFAULT_TH_CLASSES} gl-w-3 gl-pr-3!`, + // eslint-disable-next-line @gitlab/require-i18n-strings + tdClass: `${DEFAULT_TD_CLASSES} gl-pr-3!`, + }, + { + key: 'web_url', + label: s__('BulkImport|From source group'), + thClass: `${DEFAULT_TH_CLASSES} gl-pl-0! import-jobs-from-col`, + // eslint-disable-next-line @gitlab/require-i18n-strings + tdClass: `${DEFAULT_TD_CLASSES} gl-pl-0!`, + }, + { + key: 'import_target', + label: s__('BulkImport|To new group'), + thClass: `${DEFAULT_TH_CLASSES} import-jobs-to-col`, + tdClass: DEFAULT_TD_CLASSES, + }, + { + key: 'progress', + label: __('Status'), + thClass: `${DEFAULT_TH_CLASSES} import-jobs-status-col`, + tdClass: DEFAULT_TD_CLASSES, + tdAttr: { 'data-qa-selector': 'import_status_indicator' }, + }, + { + key: 'actions', + label: '', + thClass: `${DEFAULT_TH_CLASSES} import-jobs-cta-col`, + tdClass: DEFAULT_TD_CLASSES, + }, + ], + computed: { groups() { return this.bulkImportSourceGroups?.nodes ?? []; }, - hasGroupsWithValidationError() { - return this.groups.some((g) => g.validation_errors.length); + hasSelectedGroups() { + return this.selectedGroups.length > 0; }, - availableGroupsForImport() { - return this.groups.filter((g) => g.progress.status === STATUSES.NONE); + hasAllAvailableGroupsSelected() { + return this.selectedGroups.length === this.availableGroupsForImport.length; }, - isImportAllButtonDisabled() { - return this.hasGroupsWithValidationError || this.availableGroupsForImport.length === 0; + availableGroupsForImport() { + return this.groups.filter((g) => g.progress.status === STATUSES.NONE && !this.isInvalid(g)); }, humanizedTotal() { @@ -117,7 +166,7 @@ export default { total: 0, }; const start = (page - 1) * perPage + 1; - const end = start + (this.bulkImportSourceGroups.nodes?.length ?? 0) - 1; + const end = start + this.groups.length - 1; return { start, end, total }; }, @@ -127,9 +176,39 @@ export default { filter() { this.page = 1; }, + groups() { + const table = this.getTableRef(); + this.groups.forEach((g, idx) => { + if (this.selectedGroups.includes(g)) { + this.$nextTick(() => { + table.selectRow(idx); + }); + } + }); + this.selectedGroups = []; + }, }, methods: { + qaRowAttributes(group, type) { + if (type === 'row') { + return { + 'data-qa-selector': 'import_item', + 'data-qa-source-group': group.full_path, + }; + } + + return {}; + }, + + isAlreadyImported(group) { + return group.progress.status !== STATUSES.NONE; + }, + + isInvalid(group) { + return isInvalid(group, this.groupPathRegex); + }, + groupsCount(count) { return n__('%d group', '%d groups', count); }, @@ -138,17 +217,10 @@ export default { this.page = page; }, - updateTargetNamespace(sourceGroupId, targetNamespace) { + updateImportTarget(sourceGroupId, targetNamespace, newName) { this.$apollo.mutate({ - mutation: setTargetNamespaceMutation, - variables: { sourceGroupId, targetNamespace }, - }); - }, - - updateNewName(sourceGroupId, newName) { - this.$apollo.mutate({ - mutation: setNewNameMutation, - variables: { sourceGroupId, newName }, + mutation: setImportTargetMutation, + variables: { sourceGroupId, targetNamespace, newName }, }); }, @@ -159,13 +231,33 @@ export default { }); }, - importAllGroups() { - this.importGroups(this.availableGroupsForImport.map((g) => g.id)); + importSelectedGroups() { + this.importGroups(this.selectedGroups.map((g) => g.id)); }, setPageSize(size) { this.perPage = size; }, + + getTableRef() { + // Acquire reference to BTable to manipulate selection + // issue: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1531 + // refs are not reactive, so do not use computed here + return this.$refs.table?.$children[0]; + }, + + preventSelectingAlreadyImportedGroups(updatedSelection) { + if (updatedSelection) { + this.selectedGroups = updatedSelection; + } + + const table = this.getTableRef(); + this.groups.forEach((group, idx) => { + if (table.isRowSelected(idx) && (this.isAlreadyImported(group) || this.isInvalid(group))) { + table.unselectRow(idx); + } + }); + }, }, gitlabLogo: window.gon.gitlab_logo, @@ -180,28 +272,6 @@ export default { > <img :src="$options.gitlabLogo" class="gl-w-6 gl-h-6 gl-mb-2 gl-display-inline gl-mr-2" /> {{ s__('BulkImport|Import groups from GitLab') }} - <div ref="importAllButtonWrapper" class="gl-ml-auto"> - <gl-button - v-if="!$apollo.loading && hasGroups" - :disabled="isImportAllButtonDisabled" - variant="confirm" - @click="importAllGroups" - > - <gl-sprintf :message="s__('BulkImport|Import %{groups}')"> - <template #groups> - {{ groupsCount(availableGroupsForImport.length) }} - </template> - </gl-sprintf> - </gl-button> - </div> - <gl-tooltip v-if="isImportAllButtonDisabled" :target="() => $refs.importAllButtonWrapper"> - <template v-if="hasGroupsWithValidationError"> - {{ s__('BulkImport|One or more groups has validation errors') }} - </template> - <template v-else> - {{ s__('BulkImport|No groups on this page are available for import') }} - </template> - </gl-tooltip> </h1> <div class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex" @@ -247,27 +317,92 @@ export default { :description="s__('Check your source instance permissions.')" /> <template v-else> - <table class="gl-w-full" data-qa-selector="import_table"> - <thead class="gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1"> - <th class="gl-py-4 import-jobs-from-col">{{ s__('BulkImport|From source group') }}</th> - <th class="gl-py-4 import-jobs-to-col">{{ s__('BulkImport|To new group') }}</th> - <th class="gl-py-4 import-jobs-status-col">{{ __('Status') }}</th> - <th class="gl-py-4 import-jobs-cta-col"></th> - </thead> - <tbody class="gl-vertical-align-top"> - <template v-for="group in bulkImportSourceGroups.nodes"> - <import-table-row - :key="group.id" - :group="group" - :available-namespaces="availableNamespaces" - :group-path-regex="groupPathRegex" - @update-target-namespace="updateTargetNamespace(group.id, $event)" - @update-new-name="updateNewName(group.id, $event)" - @import-group="importGroups([group.id])" - /> + <div + class="gl-bg-gray-10 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-p-4 gl-display-flex gl-align-items-center" + > + <gl-sprintf :message="__('%{count} selected')"> + <template #count> + {{ selectedGroups.length }} </template> - </tbody> - </table> + </gl-sprintf> + <gl-button + category="primary" + variant="confirm" + class="gl-ml-4" + :disabled="!hasSelectedGroups" + @click="importSelectedGroups" + >{{ s__('BulkImport|Import selected') }}</gl-button + > + </div> + <gl-table + ref="table" + class="gl-w-full" + data-qa-selector="import_table" + tbody-tr-class="gl-border-gray-200 gl-border-0 gl-border-b-1 gl-border-solid" + :tbody-tr-attr="qaRowAttributes" + :items="groups" + :fields="$options.fields" + selectable + select-mode="multi" + selected-variant="primary" + @row-selected="preventSelectingAlreadyImportedGroups" + > + <template #head(selected)="{ selectAllRows, clearSelected }"> + <gl-form-checkbox + :key="`checkbox-${selectedGroups.length}`" + class="gl-h-7 gl-pt-3" + :checked="hasSelectedGroups" + :indeterminate="hasSelectedGroups && !hasAllAvailableGroupsSelected" + @change="hasAllAvailableGroupsSelected ? clearSelected() : selectAllRows()" + /> + </template> + <template #cell(selected)="{ rowSelected, selectRow, unselectRow, item: group }"> + <gl-form-checkbox + class="gl-h-7 gl-pt-3" + :checked="rowSelected" + :disabled="isAlreadyImported(group) || isInvalid(group)" + @change="rowSelected ? unselectRow() : selectRow()" + /> + </template> + <template #cell(web_url)="{ value: web_url, item: { full_path } }"> + <gl-link + :href="web_url" + target="_blank" + class="gl-display-inline-flex gl-align-items-center gl-h-7" + > + {{ full_path }} <gl-icon name="external-link" /> + </gl-link> + </template> + <template #cell(import_target)="{ item: group }"> + <import-target-cell + :group="group" + :available-namespaces="availableNamespaces" + :group-path-regex="groupPathRegex" + :group-url-error-message="groupUrlErrorMessage" + @update-target-namespace=" + updateImportTarget(group.id, $event, group.import_target.new_name) + " + @update-new-name=" + updateImportTarget(group.id, group.import_target.target_namespace, $event) + " + /> + </template> + <template #cell(progress)="{ value: { status } }"> + <import-status :status="status" class="gl-line-height-32" /> + </template> + <template #cell(actions)="{ item: group }"> + <gl-button + v-if="!isAlreadyImported(group)" + :disabled="isInvalid(group)" + variant="confirm" + category="secondary" + data-qa-selector="import_group_button" + @click="importGroups([group.id])" + > + {{ __('Import') }} + </gl-button> + </template> + </gl-table> <div v-if="hasGroups" class="gl-display-flex gl-mt-3 gl-align-items-center"> <pagination-links :change="setPage" diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue deleted file mode 100644 index 1c3ede769e0..00000000000 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue +++ /dev/null @@ -1,227 +0,0 @@ -<script> -import { - GlButton, - GlDropdownDivider, - GlDropdownItem, - GlDropdownSectionHeader, - GlIcon, - GlLink, - GlFormInput, -} from '@gitlab/ui'; -import { joinPaths } from '~/lib/utils/url_utility'; -import { s__ } from '~/locale'; -import ImportGroupDropdown from '../../components/group_dropdown.vue'; -import ImportStatus from '../../components/import_status.vue'; -import { STATUSES } from '../../constants'; -import addValidationErrorMutation from '../graphql/mutations/add_validation_error.mutation.graphql'; -import removeValidationErrorMutation from '../graphql/mutations/remove_validation_error.mutation.graphql'; -import groupAndProjectQuery from '../graphql/queries/groupAndProject.query.graphql'; - -const DEBOUNCE_INTERVAL = 300; - -export default { - components: { - ImportStatus, - ImportGroupDropdown, - GlButton, - GlDropdownDivider, - GlDropdownItem, - GlDropdownSectionHeader, - GlLink, - GlIcon, - GlFormInput, - }, - props: { - group: { - type: Object, - required: true, - }, - availableNamespaces: { - type: Array, - required: true, - }, - groupPathRegex: { - type: RegExp, - required: true, - }, - }, - - apollo: { - existingGroupAndProject: { - query: groupAndProjectQuery, - debounce: DEBOUNCE_INTERVAL, - variables() { - return { - fullPath: this.fullPath, - }; - }, - update({ existingGroup, existingProject }) { - const variables = { - field: 'new_name', - sourceGroupId: this.group.id, - }; - - if (!existingGroup && !existingProject) { - this.$apollo.mutate({ - mutation: removeValidationErrorMutation, - variables, - }); - } else { - this.$apollo.mutate({ - mutation: addValidationErrorMutation, - variables: { - ...variables, - message: this.$options.i18n.NAME_ALREADY_EXISTS, - }, - }); - } - }, - skip() { - return !this.isNameValid || this.isAlreadyImported; - }, - }, - }, - - computed: { - availableNamespaceNames() { - return this.availableNamespaces.map((ns) => ns.full_path); - }, - - importTarget() { - return this.group.import_target; - }, - - invalidNameValidationMessage() { - return this.group.validation_errors.find(({ field }) => field === 'new_name')?.message; - }, - - isInvalid() { - return Boolean(!this.isNameValid || this.invalidNameValidationMessage); - }, - - isNameValid() { - return this.groupPathRegex.test(this.importTarget.new_name); - }, - - isAlreadyImported() { - return this.group.progress.status !== STATUSES.NONE; - }, - - isFinished() { - return this.group.progress.status === STATUSES.FINISHED; - }, - - fullPath() { - return `${this.importTarget.target_namespace}/${this.importTarget.new_name}`; - }, - - absolutePath() { - return joinPaths(gon.relative_url_root || '/', this.fullPath); - }, - }, - - i18n: { - NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'), - }, -}; -</script> - -<template> - <tr - class="gl-border-gray-200 gl-border-0 gl-border-b-1 gl-border-solid" - data-qa-selector="import_item" - :data-qa-source-group="group.full_path" - > - <td class="gl-p-4"> - <gl-link - :href="group.web_url" - target="_blank" - class="gl-display-flex gl-align-items-center gl-h-7" - > - {{ group.full_path }} <gl-icon name="external-link" /> - </gl-link> - </td> - <td class="gl-p-4"> - <gl-link - v-if="isFinished" - class="gl-display-flex gl-align-items-center gl-h-7" - :href="absolutePath" - > - {{ fullPath }} - </gl-link> - - <div - v-else - class="import-entities-target-select gl-display-flex gl-align-items-stretch" - :class="{ - disabled: isAlreadyImported, - }" - > - <import-group-dropdown - #default="{ namespaces }" - :text="importTarget.target_namespace" - :disabled="isAlreadyImported" - :namespaces="availableNamespaceNames" - toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" - class="import-entities-namespace-dropdown gl-h-7 gl-flex-grow-1" - data-qa-selector="target_namespace_selector_dropdown" - > - <gl-dropdown-item @click="$emit('update-target-namespace', '')">{{ - s__('BulkImport|No parent') - }}</gl-dropdown-item> - <template v-if="namespaces.length"> - <gl-dropdown-divider /> - <gl-dropdown-section-header> - {{ s__('BulkImport|Existing groups') }} - </gl-dropdown-section-header> - <gl-dropdown-item - v-for="ns in namespaces" - :key="ns" - data-qa-selector="target_group_dropdown_item" - :data-qa-group-name="ns" - @click="$emit('update-target-namespace', ns)" - > - {{ ns }} - </gl-dropdown-item> - </template> - </import-group-dropdown> - <div - class="import-entities-target-select-separator gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1" - > - / - </div> - <div class="gl-flex-grow-1"> - <gl-form-input - class="gl-rounded-top-left-none gl-rounded-bottom-left-none" - :class="{ 'is-invalid': isInvalid && !isAlreadyImported }" - :disabled="isAlreadyImported" - :value="importTarget.new_name" - @input="$emit('update-new-name', $event)" - /> - <p v-if="isInvalid" class="gl-text-red-500 gl-m-0 gl-mt-2"> - <template v-if="!isNameValid"> - {{ __('Please choose a group URL with no special characters.') }} - </template> - <template v-else-if="invalidNameValidationMessage"> - {{ invalidNameValidationMessage }} - </template> - </p> - </div> - </div> - </td> - <td class="gl-p-4 gl-white-space-nowrap" data-qa-selector="import_status_indicator"> - <import-status :status="group.progress.status" class="gl-mt-2" /> - </td> - <td class="gl-p-4"> - <gl-button - v-if="!isAlreadyImported" - :disabled="isInvalid" - variant="confirm" - category="secondary" - data-qa-selector="import_group_button" - @click="$emit('import-group')" - >{{ __('Import') }}</gl-button - > - </td> - </tr> -</template> diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue new file mode 100644 index 00000000000..7359d4f239e --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue @@ -0,0 +1,162 @@ +<script> +import { + GlDropdownDivider, + GlDropdownItem, + GlDropdownSectionHeader, + GlLink, + GlFormInput, +} from '@gitlab/ui'; +import { joinPaths } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; +import ImportGroupDropdown from '../../components/group_dropdown.vue'; +import { STATUSES } from '../../constants'; +import { isInvalid, getInvalidNameValidationMessage, isNameValid } from '../utils'; + +export default { + components: { + ImportGroupDropdown, + GlDropdownDivider, + GlDropdownItem, + GlDropdownSectionHeader, + GlLink, + GlFormInput, + }, + props: { + group: { + type: Object, + required: true, + }, + availableNamespaces: { + type: Array, + required: true, + }, + groupPathRegex: { + type: RegExp, + required: true, + }, + groupUrlErrorMessage: { + type: String, + required: true, + }, + }, + + computed: { + availableNamespaceNames() { + return this.availableNamespaces.map((ns) => ns.full_path); + }, + + importTarget() { + return this.group.import_target; + }, + + invalidNameValidationMessage() { + return getInvalidNameValidationMessage(this.group); + }, + + isInvalid() { + return isInvalid(this.group, this.groupPathRegex); + }, + + isNameValid() { + return isNameValid(this.group, this.groupPathRegex); + }, + + isAlreadyImported() { + return this.group.progress.status !== STATUSES.NONE; + }, + + isFinished() { + return this.group.progress.status === STATUSES.FINISHED; + }, + + fullPath() { + return `${this.importTarget.target_namespace}/${this.importTarget.new_name}`; + }, + + absolutePath() { + return joinPaths(gon.relative_url_root || '/', this.fullPath); + }, + }, + + i18n: { + NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'), + }, +}; +</script> + +<template> + <gl-link + v-if="isFinished" + class="gl-display-inline-flex gl-align-items-center gl-h-7" + :href="absolutePath" + > + {{ fullPath }} + </gl-link> + + <div + v-else + class="gl-display-flex gl-align-items-stretch" + :class="{ + disabled: isAlreadyImported, + }" + > + <import-group-dropdown + #default="{ namespaces }" + :text="importTarget.target_namespace" + :disabled="isAlreadyImported" + :namespaces="availableNamespaceNames" + toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" + class="gl-h-7 gl-flex-grow-1" + data-qa-selector="target_namespace_selector_dropdown" + > + <gl-dropdown-item @click="$emit('update-target-namespace', '')">{{ + s__('BulkImport|No parent') + }}</gl-dropdown-item> + <template v-if="namespaces.length"> + <gl-dropdown-divider /> + <gl-dropdown-section-header> + {{ s__('BulkImport|Existing groups') }} + </gl-dropdown-section-header> + <gl-dropdown-item + v-for="ns in namespaces" + :key="ns" + data-qa-selector="target_group_dropdown_item" + :data-qa-group-name="ns" + @click="$emit('update-target-namespace', ns)" + > + {{ ns }} + </gl-dropdown-item> + </template> + </import-group-dropdown> + <div + class="gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1 gl-bg-gray-10" + :class="{ + 'gl-text-gray-400 gl-border-gray-100': isAlreadyImported, + 'gl-border-gray-200': !isAlreadyImported, + }" + > + / + </div> + <div class="gl-flex-grow-1"> + <gl-form-input + class="gl-rounded-top-left-none gl-rounded-bottom-left-none" + :class="{ + 'gl-inset-border-1-gray-200!': !isAlreadyImported, + 'gl-inset-border-1-gray-100!': isAlreadyImported, + 'is-invalid': isInvalid && !isAlreadyImported, + }" + :disabled="isAlreadyImported" + :value="importTarget.new_name" + @input="$emit('update-new-name', $event)" + /> + <p v-if="isInvalid" class="gl-text-red-500 gl-m-0 gl-mt-2"> + <template v-if="!isNameValid"> + {{ groupUrlErrorMessage }} + </template> + <template v-else-if="invalidNameValidationMessage"> + {{ invalidNameValidationMessage }} + </template> + </p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/import_entities/import_groups/constants.js b/app/assets/javascripts/import_entities/import_groups/constants.js new file mode 100644 index 00000000000..b2c3d85e280 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/constants.js @@ -0,0 +1,7 @@ +import { s__ } from '~/locale'; + +export const i18n = { + NAME_ALREADY_EXISTS: s__('BulkImport|Name already exists.'), +}; + +export const NEW_NAME_FIELD = 'new_name'; diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js index 2cde3781a6a..57188441158 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js +++ b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js @@ -4,11 +4,15 @@ import axios from '~/lib/utils/axios_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import { STATUSES } from '../../constants'; +import { i18n, NEW_NAME_FIELD } from '../constants'; import bulkImportSourceGroupItemFragment from './fragments/bulk_import_source_group_item.fragment.graphql'; +import addValidationErrorMutation from './mutations/add_validation_error.mutation.graphql'; +import removeValidationErrorMutation from './mutations/remove_validation_error.mutation.graphql'; import setImportProgressMutation from './mutations/set_import_progress.mutation.graphql'; import updateImportStatusMutation from './mutations/update_import_status.mutation.graphql'; import availableNamespacesQuery from './queries/available_namespaces.query.graphql'; import bulkImportSourceGroupQuery from './queries/bulk_import_source_group.query.graphql'; +import groupAndProjectQuery from './queries/group_and_project.query.graphql'; import { SourceGroupsManager } from './services/source_groups_manager'; import { StatusPoller } from './services/status_poller'; import typeDefs from './typedefs.graphql'; @@ -46,6 +50,37 @@ function makeGroup(data) { return result; } +async function checkImportTargetIsValid({ client, newName, targetNamespace, sourceGroupId }) { + const { + data: { existingGroup, existingProject }, + } = await client.query({ + query: groupAndProjectQuery, + variables: { + fullPath: `${targetNamespace}/${newName}`, + }, + }); + + const variables = { + field: NEW_NAME_FIELD, + sourceGroupId, + }; + + if (!existingGroup && !existingProject) { + client.mutate({ + mutation: removeValidationErrorMutation, + variables, + }); + } else { + client.mutate({ + mutation: addValidationErrorMutation, + variables: { + ...variables, + message: i18n.NAME_ALREADY_EXISTS, + }, + }); + } +} + const localProgressId = (id) => `not-started-${id}`; export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGroupsManager }) { @@ -99,7 +134,7 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr ]) => { const pagination = parseIntPagination(normalizeHeaders(headers)); - return { + const response = { __typename: clientTypenames.BulkImportSourceGroupConnection, nodes: data.importable_data.map((group) => { const { jobId, importState: cachedImportState } = @@ -123,6 +158,21 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr ...pagination, }, }; + + setTimeout(() => { + response.nodes.forEach((group) => { + if (group.progress.status === STATUSES.NONE) { + checkImportTargetIsValid({ + client, + newName: group.import_target.new_name, + targetNamespace: group.import_target.target_namespace, + sourceGroupId: group.id, + }); + } + }); + }); + + return response; }, ); }, @@ -136,6 +186,22 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr ), }, Mutation: { + setImportTarget(_, { targetNamespace, newName, sourceGroupId }, { client }) { + checkImportTargetIsValid({ + client, + sourceGroupId, + targetNamespace, + newName, + }); + return makeGroup({ + id: sourceGroupId, + import_target: { + target_namespace: targetNamespace, + new_name: newName, + }, + }); + }, + setTargetNamespace: (_, { targetNamespace, sourceGroupId }) => makeGroup({ id: sourceGroupId, diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql new file mode 100644 index 00000000000..793b60ee378 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_target.mutation.graphql @@ -0,0 +1,13 @@ +mutation setImportTarget($newName: String!, $targetNamespace: String!, $sourceGroupId: String!) { + setImportTarget( + newName: $newName + targetNamespace: $targetNamespace + sourceGroupId: $sourceGroupId + ) @client { + id + import_target { + new_name + target_namespace + } + } +} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql deleted file mode 100644 index 354bf2a5815..00000000000 --- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql +++ /dev/null @@ -1,8 +0,0 @@ -mutation setNewName($newName: String!, $sourceGroupId: String!) { - setNewName(newName: $newName, sourceGroupId: $sourceGroupId) @client { - id - import_target { - new_name - } - } -} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql deleted file mode 100644 index a0ef407f135..00000000000 --- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql +++ /dev/null @@ -1,8 +0,0 @@ -mutation setTargetNamespace($targetNamespace: String!, $sourceGroupId: String!) { - setTargetNamespace(targetNamespace: $targetNamespace, sourceGroupId: $sourceGroupId) @client { - id - import_target { - target_namespace - } - } -} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/groupAndProject.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/group_and_project.query.graphql index d6124f84025..d6124f84025 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/queries/groupAndProject.query.graphql +++ b/app/assets/javascripts/import_entities/import_groups/graphql/queries/group_and_project.query.graphql diff --git a/app/assets/javascripts/import_entities/import_groups/index.js b/app/assets/javascripts/import_entities/import_groups/index.js index cc60c8cbdb0..07b839c5c82 100644 --- a/app/assets/javascripts/import_entities/import_groups/index.js +++ b/app/assets/javascripts/import_entities/import_groups/index.js @@ -17,6 +17,7 @@ export function mountImportGroupsApp(mountElement) { jobsPath, sourceUrl, groupPathRegex, + groupUrlErrorMessage, } = mountElement.dataset; const apolloProvider = new VueApollo({ defaultClient: createApolloClient({ @@ -38,6 +39,7 @@ export function mountImportGroupsApp(mountElement) { props: { sourceUrl, groupPathRegex: new RegExp(`^(${groupPathRegex})$`), + groupUrlErrorMessage, }, }); }, diff --git a/app/assets/javascripts/import_entities/import_groups/utils.js b/app/assets/javascripts/import_entities/import_groups/utils.js new file mode 100644 index 00000000000..b451008b6f9 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/utils.js @@ -0,0 +1,13 @@ +import { NEW_NAME_FIELD } from './constants'; + +export function isNameValid(group, validationRegex) { + return validationRegex.test(group.import_target[NEW_NAME_FIELD]); +} + +export function getInvalidNameValidationMessage(group) { + return group.validation_errors.find(({ field }) => field === NEW_NAME_FIELD)?.message; +} + +export function isInvalid(group, validationRegex) { + return Boolean(!isNameValid(group, validationRegex) || getInvalidNameValidationMessage(group)); +} diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue index e2fd608d9db..a97af5367fb 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue @@ -103,6 +103,7 @@ export default { <tr class="gl-h-11 gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100 gl-h-11" data-qa-selector="project_import_row" + :data-qa-source-project="repo.importSource.fullName" > <td class="gl-p-4"> <gl-link :href="repo.importSource.providerLink" target="_blank" data-testid="providerLink" @@ -155,7 +156,7 @@ export default { </template> <template v-else-if="repo.importedProject">{{ displayFullPath }}</template> </td> - <td class="gl-p-4"> + <td class="gl-p-4" data-qa-selector="import_status_indicator"> <import-status :status="importStatus" /> </td> <td data-testid="actions"> diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue index 3655f94f06f..1fd4083b920 100644 --- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue +++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue @@ -1,6 +1,12 @@ <script> -/* eslint-disable vue/no-v-html */ -import { GlFormGroup, GlFormCheckbox, GlFormInput, GlFormSelect, GlFormTextarea } from '@gitlab/ui'; +import { + GlFormGroup, + GlFormCheckbox, + GlFormInput, + GlFormSelect, + GlFormTextarea, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; import { capitalize, lowerCase, isEmpty } from 'lodash'; import { mapGetters } from 'vuex'; import eventHub from '../event_hub'; @@ -14,6 +20,9 @@ export default { GlFormSelect, GlFormTextarea, }, + directives: { + SafeHtml, + }, props: { choices: { type: Array, @@ -122,6 +131,9 @@ export default { this.validated = true; }, }, + helpHtmlConfig: { + ADD_ATTR: ['target'], // allow external links, can be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1427 is implemented + }, }; </script> @@ -133,7 +145,7 @@ export default { :state="valid" > <template #description> - <span v-html="help"></span> + <span v-safe-html:[$options.helpHtmlConfig]="help"></span> </template> <template v-if="isCheckbox"> diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index 91f7c7dabf6..63f007170d0 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -86,7 +86,9 @@ export default { }, }, helpHtmlConfig: { + ADD_ATTR: ['target'], // allow external links, can be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1427 is implemented ADD_TAGS: ['use'], // to support icon SVGs + FORBID_ATTR: [], // This is trusted input so we can override the default config to allow data-* attributes }, }; </script> diff --git a/app/assets/javascripts/integrations/overrides/api.js b/app/assets/javascripts/integrations/overrides/api.js new file mode 100644 index 00000000000..a379a864f9c --- /dev/null +++ b/app/assets/javascripts/integrations/overrides/api.js @@ -0,0 +1,10 @@ +import axios from '~/lib/utils/axios_utils'; + +export const fetchOverrides = (overridesPath, { page, perPage }) => { + return axios.get(overridesPath, { + params: { + page, + per_page: perPage, + }, + }); +}; diff --git a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue new file mode 100644 index 00000000000..707ac946b98 --- /dev/null +++ b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue @@ -0,0 +1,127 @@ +<script> +import { GlLink, GlLoadingIcon, GlPagination, GlTable } from '@gitlab/ui'; + +import { DEFAULT_PER_PAGE } from '~/api'; +import createFlash from '~/flash'; +import { fetchOverrides } from '~/integrations/overrides/api'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import { truncateNamespace } from '~/lib/utils/text_utility'; +import { __, s__ } from '~/locale'; +import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; + +export default { + name: 'IntegrationOverrides', + components: { + GlLink, + GlLoadingIcon, + GlPagination, + GlTable, + ProjectAvatar, + }, + props: { + overridesPath: { + type: String, + required: true, + }, + }, + fields: [ + { + key: 'name', + label: __('Project'), + }, + ], + data() { + return { + isLoading: true, + overrides: [], + page: 1, + totalItems: 0, + }; + }, + computed: { + showPagination() { + return this.totalItems > this.$options.DEFAULT_PER_PAGE && this.overrides.length > 0; + }, + }, + mounted() { + this.loadOverrides(); + }, + methods: { + loadOverrides(page = this.page) { + this.isLoading = true; + + fetchOverrides(this.overridesPath, { + page, + perPage: this.$options.DEFAULT_PER_PAGE, + }) + .then(({ data, headers }) => { + const { page: newPage, total } = parseIntPagination(normalizeHeaders(headers)); + this.page = newPage; + this.totalItems = total; + this.overrides = data; + }) + .catch((error) => { + createFlash({ + message: this.$options.i18n.defaultErrorMessage, + error, + captureError: true, + }); + }) + .finally(() => { + this.isLoading = false; + }); + }, + truncateNamespace, + }, + DEFAULT_PER_PAGE, + i18n: { + defaultErrorMessage: s__( + 'Integrations|An error occurred while loading projects using custom settings.', + ), + tableEmptyText: s__('Integrations|There are no projects using custom settings'), + }, +}; +</script> + +<template> + <div> + <gl-table + :items="overrides" + :fields="$options.fields" + :busy="isLoading" + show-empty + :empty-text="$options.i18n.tableEmptyText" + > + <template #cell(name)="{ item }"> + <gl-link + class="gl-display-inline-flex gl-align-items-center gl-hover-text-decoration-none gl-text-body!" + :href="item.full_path" + > + <project-avatar + class="gl-mr-3" + :project-avatar-url="item.avatar_url" + :project-name="item.name" + aria-hidden="true" + /> + {{ truncateNamespace(item.full_name) }} / + + <strong>{{ item.name }}</strong> + </gl-link> + </template> + + <template #table-busy> + <gl-loading-icon size="md" class="gl-my-2" /> + </template> + </gl-table> + <div class="gl-display-flex gl-justify-content-center gl-mt-5"> + <gl-pagination + v-if="showPagination" + :per-page="$options.DEFAULT_PER_PAGE" + :total-items="totalItems" + :value="page" + :disabled="isLoading" + @input="loadOverrides" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/integrations/overrides/index.js b/app/assets/javascripts/integrations/overrides/index.js new file mode 100644 index 00000000000..0f03b23ba21 --- /dev/null +++ b/app/assets/javascripts/integrations/overrides/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import IntegrationOverrides from './components/integration_overrides.vue'; + +export default () => { + const el = document.querySelector('.js-vue-integration-overrides'); + + if (!el) { + return null; + } + + const { overridesPath } = el.dataset; + + return new Vue({ + el, + render(createElement) { + return createElement(IntegrationOverrides, { + props: { + overridesPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue index 2d1e57a1177..216078ed35e 100644 --- a/app/assets/javascripts/invite_members/components/group_select.vue +++ b/app/assets/javascripts/invite_members/components/group_select.vue @@ -111,6 +111,7 @@ export default { data-testid="group-select-dropdown" :text="selectedGroupName" block + toggle-class="gl-mb-2" menu-class="gl-w-full!" > <gl-search-box-by-type diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue index 4aab1123af9..ab42e8cdfeb 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -9,13 +9,19 @@ import { GlSprintf, GlButton, GlFormInput, + GlFormCheckboxGroup, } from '@gitlab/ui'; import { partition, isString } from 'lodash'; import Api from '~/api'; import ExperimentTracking from '~/experimentation/experiment_tracking'; -import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { s__, sprintf } from '~/locale'; -import { INVITE_MEMBERS_IN_COMMENT, GROUP_FILTERS } from '../constants'; +import { + INVITE_MEMBERS_IN_COMMENT, + GROUP_FILTERS, + USERS_FILTER_ALL, + MEMBER_AREAS_OF_FOCUS, +} from '../constants'; import eventHub from '../event_hub'; import { responseMessageFromError, @@ -36,6 +42,7 @@ export default { GlSprintf, GlButton, GlFormInput, + GlFormCheckboxGroup, MembersTokenSelect, GroupSelect, }, @@ -70,10 +77,28 @@ export default { required: false, default: null, }, + usersFilter: { + type: String, + required: false, + default: USERS_FILTER_ALL, + }, + filterId: { + type: Number, + required: false, + default: null, + }, helpLink: { type: String, required: true, }, + areasOfFocusOptions: { + type: Array, + required: true, + }, + noSelectionAreasOfFocus: { + type: Array, + required: true, + }, }, data() { return { @@ -83,9 +108,11 @@ export default { inviteeType: 'members', newUsersToInvite: [], selectedDate: undefined, + selectedAreasOfFocus: [], groupToBeSharedWith: {}, source: 'unknown', invalidFeedbackMessage: '', + isLoading: false, }; }, computed: { @@ -127,10 +154,28 @@ export default { this.newUsersToInvite.length === 0 && Object.keys(this.groupToBeSharedWith).length === 0 ); }, + areasOfFocusEnabled() { + return this.areasOfFocusOptions.length !== 0; + }, + areasOfFocusForPost() { + if (this.selectedAreasOfFocus.length === 0 && this.areasOfFocusEnabled) { + return this.noSelectionAreasOfFocus; + } + + return this.selectedAreasOfFocus; + }, + errorFieldDescription() { + if (this.inviteeType === 'group') { + return ''; + } + + return this.$options.labels[this.inviteeType].placeHolder; + }, }, mounted() { eventHub.$on('openModal', (options) => { this.openModal(options); + this.trackEvent(MEMBER_AREAS_OF_FOCUS.name, MEMBER_AREAS_OF_FOCUS.view); }); }, methods: { @@ -151,9 +196,13 @@ export default { this.$root.$emit(BV_SHOW_MODAL, this.modalId); }, + trackEvent(experimentName, eventName) { + const tracking = new ExperimentTracking(experimentName); + tracking.event(eventName); + }, closeModal() { this.resetFields(); - this.$root.$emit(BV_HIDE_MODAL, this.modalId); + this.$refs.modal.hide(); }, sendInvite() { if (this.isInviteGroup) { @@ -164,16 +213,19 @@ export default { }, trackInvite() { if (this.source === INVITE_MEMBERS_IN_COMMENT) { - const tracking = new ExperimentTracking(INVITE_MEMBERS_IN_COMMENT); - tracking.event('comment_invite_success'); + this.trackEvent(INVITE_MEMBERS_IN_COMMENT, 'comment_invite_success'); } + + this.trackEvent(MEMBER_AREAS_OF_FOCUS.name, MEMBER_AREAS_OF_FOCUS.submit); }, resetFields() { + this.isLoading = false; this.selectedAccessLevel = this.defaultAccessLevel; this.selectedDate = undefined; this.newUsersToInvite = []; this.groupToBeSharedWith = {}; this.invalidFeedbackMessage = ''; + this.selectedAreasOfFocus = []; }, changeSelectedItem(item) { this.selectedAccessLevel = item; @@ -189,6 +241,7 @@ export default { }, submitInviteMembers() { this.invalidFeedbackMessage = ''; + this.isLoading = true; const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite(); const promises = []; @@ -220,6 +273,7 @@ export default { email: usersToInviteByEmail, access_level: this.selectedAccessLevel, invite_source: this.source, + areas_of_focus: this.areasOfFocusForPost, }; }, addByUserIdPostData(usersToAddById) { @@ -228,6 +282,7 @@ export default { user_id: usersToAddById, access_level: this.selectedAccessLevel, invite_source: this.source, + areas_of_focus: this.areasOfFocusForPost, }; }, shareWithGroupPostData(groupToBeSharedWith) { @@ -247,12 +302,14 @@ export default { } this.invalidFeedbackMessage = message; + this.isLoading = false; }, showToastMessageSuccess() { this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); this.closeModal(); }, showInvalidFeedbackMessage(response) { + this.isLoading = false; this.invalidFeedbackMessage = responseMessageFromError(response) || this.$options.labels.invalidFeedbackMessageDefault; }, @@ -299,18 +356,24 @@ export default { inviteButtonText: s__('InviteMembersModal|Invite'), cancelButtonText: s__('InviteMembersModal|Cancel'), headerCloseLabel: s__('InviteMembersModal|Close invite team members'), + areasOfFocusLabel: s__( + 'InviteMembersModal|What would you like new member(s) to focus on? (optional)', + ), }, membersTokenSelectLabelId: 'invite-members-input', }; </script> <template> <gl-modal + ref="modal" :modal-id="modalId" size="sm" data-qa-selector="invite_members_modal_content" :title="$options.labels[inviteeType].modalTitle" :header-close-label="$options.labels.headerCloseLabel" + @hidden="resetFields" @close="resetFields" + @hide="resetFields" > <div> <p ref="introText"> @@ -322,10 +385,9 @@ export default { </p> <gl-form-group - class="gl-mt-2" :invalid-feedback="invalidFeedbackMessage" :state="validationState" - :description="$options.labels[inviteeType].placeHolder" + :description="errorFieldDescription" data-testid="members-form-group" > <label :id="$options.membersTokenSelectLabelId" class="col-form-label">{{ @@ -334,8 +396,11 @@ export default { <members-token-select v-if="!isInviteGroup" v-model="newUsersToInvite" + class="gl-mb-2" :validation-state="validationState" :aria-labelledby="$options.membersTokenSelectLabelId" + :users-filter="usersFilter" + :filter-id="filterId" @clear="handleMembersTokenSelectClear" /> <group-select @@ -343,10 +408,11 @@ export default { v-model="groupToBeSharedWith" :groups-filter="groupSelectFilter" :parent-group-id="groupSelectParentId" + @input="handleMembersTokenSelectClear" /> </gl-form-group> - <label class="gl-font-weight-bold gl-mt-3">{{ $options.labels.accessLevel }}</label> + <label class="gl-font-weight-bold">{{ $options.labels.accessLevel }}</label> <div class="gl-mt-2 gl-w-half gl-xs-w-full"> <gl-dropdown class="gl-shadow-none gl-w-full" @@ -376,7 +442,7 @@ export default { </gl-sprintf> </div> - <label class="gl-font-weight-bold gl-mt-5 gl-display-block" for="expires_at">{{ + <label class="gl-mt-5 gl-display-block" for="expires_at">{{ $options.labels.accessExpireDate }}</label> <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block"> @@ -395,6 +461,16 @@ export default { </template> </gl-datepicker> </div> + <div v-if="areasOfFocusEnabled"> + <label class="gl-mt-5"> + {{ $options.labels.areasOfFocusLabel }} + </label> + <gl-form-checkbox-group + v-model="selectedAreasOfFocus" + :options="areasOfFocusOptions" + data-testid="area-of-focus-checks" + /> + </div> </div> <template #modal-footer> @@ -405,6 +481,7 @@ export default { <div class="gl-mr-3"></div> <gl-button :disabled="inviteDisabled" + :loading="isLoading" variant="success" data-qa-selector="invite_button" data-testid="invite-button" diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue index 7aece3b7bb4..e299e3f27b3 100644 --- a/app/assets/javascripts/invite_members/components/members_token_select.vue +++ b/app/assets/javascripts/invite_members/components/members_token_select.vue @@ -3,7 +3,7 @@ import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlIcon, GlSprintf } from '@ import { debounce } from 'lodash'; import { __ } from '~/locale'; import { getUsers } from '~/rest_api'; -import { SEARCH_DELAY } from '../constants'; +import { SEARCH_DELAY, USERS_FILTER_ALL, USERS_FILTER_SAML_PROVIDER_ID } from '../constants'; export default { components: { @@ -26,6 +26,16 @@ export default { validationState: { type: Boolean, required: false, + default: false, + }, + usersFilter: { + type: String, + required: false, + default: USERS_FILTER_ALL, + }, + filterId: { + type: Number, + required: false, default: null, }, }, @@ -51,6 +61,15 @@ export default { } return ''; }, + queryOptions() { + if (this.usersFilter === USERS_FILTER_SAML_PROVIDER_ID) { + return { + saml_provider_id: this.filterId, + ...this.$options.defaultQueryOptions, + }; + } + return this.$options.defaultQueryOptions; + }, }, methods: { handleTextInput(query) { @@ -60,7 +79,7 @@ export default { this.retrieveUsers(query); }, retrieveUsers: debounce(function debouncedRetrieveUsers() { - return getUsers(this.query, this.$options.queryOptions) + return getUsers(this.query, this.queryOptions) .then((response) => { this.users = response.data.map((token) => ({ id: token.id, @@ -98,7 +117,7 @@ export default { this.$emit('clear'); }, }, - queryOptions: { exclude_internal: true, active: true }, + defaultQueryOptions: { exclude_internal: true, active: true }, i18n: { inviteTextMessage: __('Invite "%{email}" by email'), }, diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index 83e6cac0ac0..d7daf83e26b 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -3,6 +3,11 @@ import { __ } from '~/locale'; export const SEARCH_DELAY = 200; export const INVITE_MEMBERS_IN_COMMENT = 'invite_members_in_comment'; +export const MEMBER_AREAS_OF_FOCUS = { + name: 'member_areas_of_focus', + view: 'view', + submit: 'submit', +}; export const GROUP_FILTERS = { ALL: 'all', @@ -12,3 +17,5 @@ export const GROUP_FILTERS = { export const API_MESSAGES = { EMAIL_ALREADY_INVITED: __('Invite email has already been taken'), }; +export const USERS_FILTER_ALL = 'all'; +export const USERS_FILTER_SAML_PROVIDER_ID = 'saml_provider_id'; diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js index 7501e9f4e6e..c1dfaa25dc7 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -23,6 +23,10 @@ export default function initInviteMembersModal() { defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10), groupSelectFilter: el.dataset.groupsFilter, groupSelectParentId: parseInt(el.dataset.parentId, 10), + areasOfFocusOptions: JSON.parse(el.dataset.areasOfFocusOptions), + noSelectionAreasOfFocus: JSON.parse(el.dataset.noSelectionAreasOfFocus), + usersFilter: el.dataset.usersFilter, + filterId: parseInt(el.dataset.filterId, 10), }, }), }); diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index fd9e3d5c916..5dc49d3ae15 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -51,15 +51,16 @@ export default class IssuableForm { this.resetAutosave = this.resetAutosave.bind(this); this.handleSubmit = this.handleSubmit.bind(this); /* eslint-disable @gitlab/require-i18n-strings */ - this.wipRegex = new RegExp( + // prettier-ignore + this.draftRegex = new RegExp( '^\\s*(' + // Line start, then any amount of leading whitespace 'draft\\s-\\s' + // Draft_-_ where "_" are *exactly* one whitespace - '|\\[draft\\]\\s*' + // [Draft] or [WIP] and any following whitespace - '|draft:\\s*' + // Draft: or WIP: and any following whitespace - '|draft\\s+' + // Draft_ or WIP_ where "_" is at least one whitespace + '|\\[draft\\]\\s*' + // [Draft] and any following whitespace + '|draft:\\s*' + // Draft: and any following whitespace + '|draft\\s+' + // Draft_ where "_" is at least one whitespace '|\\(draft\\)\\s*' + // (Draft) and any following whitespace - ')+' + // At least one repeated match of the preceding parenthetical - '\\s*', // Any amount of trailing whitespace + ')+' + // At least one repeated match of the preceding parenthetical + '\\s*', // Any amount of trailing whitespace 'i', // Match any case(s) ); /* eslint-enable @gitlab/require-i18n-strings */ @@ -144,7 +145,7 @@ export default class IssuableForm { } workInProgress() { - return this.wipRegex.test(this.titleField.val()); + return this.draftRegex.test(this.titleField.val()); } renderWipExplanation() { @@ -170,7 +171,7 @@ export default class IssuableForm { } removeWip() { - return this.titleField.val(this.titleField.val().replace(this.wipRegex, '')); + return this.titleField.val(this.titleField.val().replace(this.draftRegex, '')); } addWip() { diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue index 20d1dce3905..29dd0b7fed5 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_item.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue @@ -205,7 +205,7 @@ export default { >{{ issuableSymbol }}{{ issuable.iid }}</span > <span class="issuable-authored gl-display-none gl-sm-display-inline-block! gl-mr-3"> - · + <span aria-hidden="true">·</span> <span v-gl-tooltip:tooltipcontainer.bottom data-testid="issuable-created-at" @@ -229,17 +229,19 @@ export default { </span> <slot name="timeframe"></slot> - <gl-label - v-for="(label, index) in labels" - :key="index" - :background-color="label.color" - :title="labelTitle(label)" - :description="label.description" - :scoped="scopedLabel(label)" - :target="labelTarget(label)" - :class="{ 'gl-ml-2': index }" - size="sm" - /> + <span v-if="labels.length" role="group" :aria-label="__('Labels')"> + <gl-label + v-for="(label, index) in labels" + :key="index" + :background-color="label.color" + :title="labelTitle(label)" + :description="label.description" + :scoped="scopedLabel(label)" + :target="labelTarget(label)" + :class="{ 'gl-ml-2': index }" + size="sm" + /> + </span> </div> </div> <div class="issuable-meta"> diff --git a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue index 977d03e62be..96b07031a11 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue @@ -49,7 +49,7 @@ export default { <span :title="tab.titleTooltip">{{ tab.title }}</span> <gl-badge v-if="tabCounts && isTabCountNumeric(tab)" - variant="neutral" + variant="muted" size="sm" class="gl-tab-counter-badge" > diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue index 9bfdbb41e23..35e7860cd9b 100644 --- a/app/assets/javascripts/issue_show/components/fields/description_template.vue +++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue @@ -15,7 +15,7 @@ export default { issuableTemplates: { type: [Object, Array], required: false, - default: () => {}, + default: () => ({}), }, projectPath: { type: String, diff --git a/app/assets/javascripts/issue_show/components/fields/type.vue b/app/assets/javascripts/issue_show/components/fields/type.vue index 1ed222531f4..3eac448c637 100644 --- a/app/assets/javascripts/issue_show/components/fields/type.vue +++ b/app/assets/javascripts/issue_show/components/fields/type.vue @@ -1,5 +1,5 @@ <script> -import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlFormGroup, GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; import { capitalize } from 'lodash'; import { __ } from '~/locale'; import { IssuableTypes } from '../../constants'; @@ -15,6 +15,7 @@ export default { IssuableTypes, components: { GlFormGroup, + GlIcon, GlDropdown, GlDropdownItem, }, @@ -72,6 +73,7 @@ export default { is-check-item @click="updateIssueType(type.value)" > + <gl-icon :name="type.icon" /> {{ type.text }} </gl-dropdown-item> </gl-dropdown> diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue index bdaa8a4dd6b..001e8abb941 100644 --- a/app/assets/javascripts/issue_show/components/form.vue +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -33,7 +33,7 @@ export default { issuableTemplates: { type: [Object, Array], required: false, - default: () => {}, + default: () => [], }, issuableType: { type: String, diff --git a/app/assets/javascripts/issue_show/constants.js b/app/assets/javascripts/issue_show/constants.js index d93f38c2ee1..64d39a79821 100644 --- a/app/assets/javascripts/issue_show/constants.js +++ b/app/assets/javascripts/issue_show/constants.js @@ -28,8 +28,8 @@ export const STATUS_PAGE_PUBLISHED = __('Published on status page'); export const JOIN_ZOOM_MEETING = __('Join Zoom meeting'); export const IssuableTypes = [ - { value: 'issue', text: __('Issue') }, - { value: 'incident', text: __('Incident') }, + { value: 'issue', text: __('Issue'), icon: 'issue-type-issue' }, + { value: 'incident', text: __('Incident'), icon: 'issue-type-incident' }, ]; export const IssueTypePath = 'issues'; diff --git a/app/assets/javascripts/issues_list/components/issuables_list_app.vue b/app/assets/javascripts/issues_list/components/issuables_list_app.vue index b13a389b963..62b52afdaca 100644 --- a/app/assets/javascripts/issues_list/components/issuables_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issuables_list_app.vue @@ -9,8 +9,7 @@ import { toNumber, omit } from 'lodash'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { scrollToElement, historyPushState } from '~/lib/utils/common_utils'; -// eslint-disable-next-line import/no-deprecated -import { setUrlParams, urlParamsToObject, getParameterByName } from '~/lib/utils/url_utility'; +import { setUrlParams, queryToObject, getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import initManualOrdering from '~/manual_ordering'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; @@ -264,8 +263,7 @@ export default { }); }, getQueryObject() { - // eslint-disable-next-line import/no-deprecated - return urlParamsToObject(window.location.search); + return queryToObject(window.location.search, { gatherArrays: true }); }, onPaginate(newPage) { if (newPage === this.page) return; diff --git a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue index 07492b0046c..a687a58a6ad 100644 --- a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue +++ b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue @@ -48,8 +48,8 @@ export default { dueDate() { return this.issue.dueDate && dateInWords(new Date(this.issue.dueDate), true); }, - isDueDateInPast() { - return isInPast(new Date(this.issue.dueDate)); + showDueDateInRed() { + return isInPast(new Date(this.issue.dueDate)) && !this.issue.closedAt; }, timeEstimate() { return this.issue.humanTimeEstimate || this.issue.timeStats?.humanTimeEstimate; @@ -97,7 +97,7 @@ export default { v-if="issue.dueDate" v-gl-tooltip class="issuable-due-date gl-display-none gl-sm-display-inline-block! gl-mr-3" - :class="{ 'gl-text-red-500': isDueDateInPast }" + :class="{ 'gl-text-red-500': showDueDateInRed }" :title="__('Due date')" data-testid="issuable-due-date" > diff --git a/app/assets/javascripts/issues_list/components/issues_list_app.vue b/app/assets/javascripts/issues_list/components/issues_list_app.vue index 6563094ef72..ee0429c0432 100644 --- a/app/assets/javascripts/issues_list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue @@ -9,10 +9,11 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import { cloneDeep } from 'lodash'; import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql'; import createFlash from '~/flash'; import { TYPE_USER } from '~/graphql_shared/constants'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; import IssuableList from '~/issuable_list/components/issuable_list_root.vue'; @@ -20,7 +21,6 @@ import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants'; import { CREATED_DESC, i18n, - initialPageParams, issuesCountSmartQueryBase, MAX_LIST_SIZE, PAGE_SIZE, @@ -36,6 +36,7 @@ import { TOKEN_TYPE_LABEL, TOKEN_TYPE_MILESTONE, TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_TYPE, TOKEN_TYPE_WEIGHT, UPDATED_DESC, urlSortParams, @@ -46,12 +47,13 @@ import { convertToUrlParams, getDueDateValue, getFilterTokens, + getInitialPageParams, getSortKey, getSortOptions, } from '~/issues_list/utils'; import axios from '~/lib/utils/axios_utils'; import { scrollUp } from '~/lib/utils/scroll_utils'; -import { getParameterByName } from '~/lib/utils/url_utility'; +import { getParameterByName, joinPaths } from '~/lib/utils/url_utility'; import { DEFAULT_NONE_ANY, OPERATOR_IS_ONLY, @@ -63,6 +65,7 @@ import { TOKEN_TITLE_LABEL, TOKEN_TITLE_MILESTONE, TOKEN_TITLE_MY_REACTION, + TOKEN_TITLE_TYPE, TOKEN_TITLE_WEIGHT, } from '~/vue_shared/components/filtered_search_bar/constants'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; @@ -73,6 +76,7 @@ import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue'; import eventHub from '../eventhub'; +import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql'; import searchIterationsQuery from '../queries/search_iterations.query.graphql'; import searchLabelsQuery from '../queries/search_labels.query.graphql'; import searchMilestonesQuery from '../queries/search_milestones.query.graphql'; @@ -160,18 +164,22 @@ export default { }, }, data() { + const filterTokens = getFilterTokens(window.location.search); const state = getParameterByName(PARAM_STATE); + const sortKey = getSortKey(getParameterByName(PARAM_SORT)); const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC; + this.initialFilterTokens = cloneDeep(filterTokens); + return { dueDateFilter: getDueDateValue(getParameterByName(PARAM_DUE_DATE)), exportCsvPathWithQuery: this.getExportCsvPathWithQuery(), - filterTokens: getFilterTokens(window.location.search), + filterTokens, issues: [], pageInfo: {}, - pageParams: initialPageParams, + pageParams: getInitialPageParams(sortKey), showBulkEditSidebar: false, - sortKey: getSortKey(getParameterByName(PARAM_SORT)) || defaultSortKey, + sortKey: sortKey || defaultSortKey, state: state || IssuableStates.Opened, }; }, @@ -275,7 +283,6 @@ export default { avatar_url: gon.current_user_avatar_url, }); } - const tokens = [ { type: TOKEN_TYPE_AUTHOR, @@ -306,7 +313,6 @@ export default { icon: 'clock', token: MilestoneToken, unique: true, - defaultMilestones: [], fetchMilestones: this.fetchMilestones, }, { @@ -317,6 +323,18 @@ export default { defaultLabels: DEFAULT_NONE_ANY, fetchLabels: this.fetchLabels, }, + { + type: TOKEN_TYPE_TYPE, + title: TOKEN_TITLE_TYPE, + icon: 'issues', + token: GlFilteredSearchToken, + operators: OPERATOR_IS_ONLY, + options: [ + { icon: 'issue-type-issue', title: 'issue', value: 'issue' }, + { icon: 'issue-type-incident', title: 'incident', value: 'incident' }, + { icon: 'issue-type-test-case', title: 'test_case', value: 'test_case' }, + ], + }, ]; if (this.isSignedIn) { @@ -518,12 +536,12 @@ export default { }, handleClickTab(state) { if (this.state !== state) { - this.pageParams = initialPageParams; + this.pageParams = getInitialPageParams(this.sortKey); } this.state = state; }, handleFilter(filter) { - this.pageParams = initialPageParams; + this.pageParams = getInitialPageParams(this.sortKey); this.filterTokens = filter; }, handleNextPage() { @@ -560,14 +578,16 @@ export default { } return axios - .put(`${this.issuesPath}/${issueToMove.iid}/reorder`, { - move_before_id: isMovingToBeginning ? null : moveBeforeId, - move_after_id: isMovingToEnd ? null : moveAfterId, + .put(joinPaths(this.issuesPath, issueToMove.iid, 'reorder'), { + move_before_id: isMovingToBeginning ? null : getIdFromGraphQLId(moveBeforeId), + move_after_id: isMovingToEnd ? null : getIdFromGraphQLId(moveAfterId), }) .then(() => { - // Move issue to new position in list - this.issues.splice(oldIndex, 1); - this.issues.splice(newIndex, 0, issueToMove); + const serializedVariables = JSON.stringify(this.queryVariables); + this.$apollo.mutate({ + mutation: reorderIssuesMutation, + variables: { oldIndex, newIndex, serializedVariables }, + }); }) .catch(() => { createFlash({ message: this.$options.i18n.reorderError }); @@ -575,7 +595,7 @@ export default { }, handleSort(sortKey) { if (this.sortKey !== sortKey) { - this.pageParams = initialPageParams; + this.pageParams = getInitialPageParams(sortKey); } this.sortKey = sortKey; }, @@ -593,7 +613,7 @@ export default { recent-searches-storage-key="issues" :search-input-placeholder="$options.i18n.searchPlaceholder" :search-tokens="searchTokens" - :initial-filter-value="filterTokens" + :initial-filter-value="initialFilterTokens" :sort-options="sortOptions" :initial-sort-by="sortKey" :issuables="issues" diff --git a/app/assets/javascripts/issues_list/components/jira_issues_import_status_app.vue b/app/assets/javascripts/issues_list/components/jira_issues_import_status_app.vue index ba0ca57523a..fb1dbef666c 100644 --- a/app/assets/javascripts/issues_list/components/jira_issues_import_status_app.vue +++ b/app/assets/javascripts/issues_list/components/jira_issues_import_status_app.vue @@ -59,9 +59,6 @@ export default { shouldShowInProgressAlert: isInProgress(project.jiraImportStatus), }; }, - skip() { - return !this.isJiraConfigured || !this.canEdit; - }, }, }, computed: { @@ -75,6 +72,9 @@ export default { labelTarget() { return `${this.issuesPath}?label_name[]=${encodeURIComponent(this.jiraImport.label.title)}`; }, + shouldRender() { + return this.jiraImport.shouldShowInProgressAlert || this.jiraImport.shouldShowFinishedAlert; + }, }, methods: { hideFinishedAlert() { @@ -89,7 +89,7 @@ export default { </script> <template> - <div class="gl-my-5"> + <div v-if="shouldRender" class="gl-my-5"> <gl-alert v-if="jiraImport.shouldShowInProgressAlert" @dismiss="hideInProgressAlert"> {{ __('Import in progress. Refresh page to see newly added issues.') }} </gl-alert> diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js index d94d4b9a19a..3f5b0d1feb5 100644 --- a/app/assets/javascripts/issues_list/constants.js +++ b/app/assets/javascripts/issues_list/constants.js @@ -5,6 +5,8 @@ import { FILTER_ANY, FILTER_CURRENT, FILTER_NONE, + FILTER_STARTED, + FILTER_UPCOMING, OPERATOR_IS, OPERATOR_IS_NOT, } from '~/vue_shared/components/filtered_search_bar/constants'; @@ -107,10 +109,14 @@ export const PARAM_DUE_DATE = 'due_date'; export const PARAM_SORT = 'sort'; export const PARAM_STATE = 'state'; -export const initialPageParams = { +export const defaultPageSizeParams = { firstPageSize: PAGE_SIZE, }; +export const largePageSizeParams = { + firstPageSize: PAGE_SIZE_MANUAL, +}; + export const DUE_DATE_NONE = '0'; export const DUE_DATE_ANY = ''; export const DUE_DATE_OVERDUE = 'overdue'; @@ -186,12 +192,19 @@ export const URL_PARAM = 'urlParam'; export const NORMAL_FILTER = 'normalFilter'; export const SPECIAL_FILTER = 'specialFilter'; export const ALTERNATIVE_FILTER = 'alternativeFilter'; -export const SPECIAL_FILTER_VALUES = [FILTER_NONE, FILTER_ANY, FILTER_CURRENT]; +export const SPECIAL_FILTER_VALUES = [ + FILTER_NONE, + FILTER_ANY, + FILTER_CURRENT, + FILTER_UPCOMING, + FILTER_STARTED, +]; export const TOKEN_TYPE_AUTHOR = 'author_username'; export const TOKEN_TYPE_ASSIGNEE = 'assignee_username'; export const TOKEN_TYPE_MILESTONE = 'milestone'; export const TOKEN_TYPE_LABEL = 'labels'; +export const TOKEN_TYPE_TYPE = 'type'; export const TOKEN_TYPE_MY_REACTION = 'my_reaction_emoji'; export const TOKEN_TYPE_CONFIDENTIAL = 'confidential'; export const TOKEN_TYPE_ITERATION = 'iteration'; @@ -231,10 +244,12 @@ export const filters = { [TOKEN_TYPE_MILESTONE]: { [API_PARAM]: { [NORMAL_FILTER]: 'milestoneTitle', + [SPECIAL_FILTER]: 'milestoneWildcardId', }, [URL_PARAM]: { [OPERATOR_IS]: { [NORMAL_FILTER]: 'milestone_title', + [SPECIAL_FILTER]: 'milestone_title', }, [OPERATOR_IS_NOT]: { [NORMAL_FILTER]: 'not[milestone_title]', @@ -256,6 +271,18 @@ export const filters = { }, }, }, + [TOKEN_TYPE_TYPE]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'types', + [SPECIAL_FILTER]: 'types', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'type[]', + [SPECIAL_FILTER]: 'type[]', + }, + }, + }, [TOKEN_TYPE_MY_REACTION]: { [API_PARAM]: { [NORMAL_FILTER]: 'myReactionEmoji', diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js index 71ceb9bef55..dcc7ee72273 100644 --- a/app/assets/javascripts/issues_list/index.js +++ b/app/assets/javascripts/issues_list/index.js @@ -1,5 +1,7 @@ +import produce from 'immer'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql'; import IssuesListApp from '~/issues_list/components/issues_list_app.vue'; import createDefaultClient from '~/lib/graphql'; import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; @@ -13,9 +15,16 @@ export function mountJiraIssuesListApp() { return false; } - Vue.use(VueApollo); + const { issuesPath, projectPath } = el.dataset; + const canEdit = parseBoolean(el.dataset.canEdit); + const isJiraConfigured = parseBoolean(el.dataset.isJiraConfigured); + + if (!isJiraConfigured || !canEdit) { + return false; + } - const defaultClient = createDefaultClient(); + Vue.use(VueApollo); + const defaultClient = createDefaultClient({}, { assumeImmutableResults: true }); const apolloProvider = new VueApollo({ defaultClient, }); @@ -26,10 +35,10 @@ export function mountJiraIssuesListApp() { render(createComponent) { return createComponent(JiraIssuesImportStatusRoot, { props: { - canEdit: parseBoolean(el.dataset.canEdit), - isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured), - issuesPath: el.dataset.issuesPath, - projectPath: el.dataset.projectPath, + canEdit, + isJiraConfigured, + issuesPath, + projectPath, }, }); }, @@ -74,7 +83,27 @@ export function mountIssuesListApp() { Vue.use(VueApollo); - const defaultClient = createDefaultClient({}, { assumeImmutableResults: true }); + const resolvers = { + Mutation: { + reorderIssues: (_, { oldIndex, newIndex, serializedVariables }, { cache }) => { + const variables = JSON.parse(serializedVariables); + const sourceData = cache.readQuery({ query: getIssuesQuery, variables }); + + const data = produce(sourceData, (draftData) => { + const issues = draftData.project.issues.nodes.slice(); + const issueToMove = issues[oldIndex]; + issues.splice(oldIndex, 1); + issues.splice(newIndex, 0, issueToMove); + + draftData.project.issues.nodes = issues; + }); + + cache.writeQuery({ query: getIssuesQuery, variables, data }); + }, + }, + }; + + const defaultClient = createDefaultClient(resolvers, { assumeImmutableResults: true }); const apolloProvider = new VueApollo({ defaultClient, }); diff --git a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues.query.graphql index 124190915c0..30a01b4c3b0 100644 --- a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues_list/queries/get_issues.query.graphql @@ -12,6 +12,8 @@ query getProjectIssues( $authorUsername: String $labelName: [String] $milestoneTitle: [String] + $milestoneWildcardId: MilestoneWildcardId + $types: [IssueType!] $not: NegatedIssueFilterInput $beforeCursor: String $afterCursor: String @@ -28,6 +30,8 @@ query getProjectIssues( authorUsername: $authorUsername labelName: $labelName milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + types: $types not: $not before: $beforeCursor after: $afterCursor diff --git a/app/assets/javascripts/issues_list/queries/get_issues_count.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues_count.query.graphql index a1742859640..e6896131da9 100644 --- a/app/assets/javascripts/issues_list/queries/get_issues_count.query.graphql +++ b/app/assets/javascripts/issues_list/queries/get_issues_count.query.graphql @@ -7,6 +7,8 @@ query getProjectIssuesCount( $authorUsername: String $labelName: [String] $milestoneTitle: [String] + $milestoneWildcardId: MilestoneWildcardId + $types: [IssueType!] $not: NegatedIssueFilterInput ) { project(fullPath: $projectPath) { @@ -18,6 +20,8 @@ query getProjectIssuesCount( authorUsername: $authorUsername labelName: $labelName milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + types: $types not: $not ) { count diff --git a/app/assets/javascripts/issues_list/queries/issue.fragment.graphql b/app/assets/javascripts/issues_list/queries/issue.fragment.graphql index f7ebf64ffb8..633b06eced8 100644 --- a/app/assets/javascripts/issues_list/queries/issue.fragment.graphql +++ b/app/assets/javascripts/issues_list/queries/issue.fragment.graphql @@ -7,6 +7,7 @@ fragment IssueFragment on Issue { downvotes dueDate humanTimeEstimate + mergeRequestsCount moved title updatedAt diff --git a/app/assets/javascripts/issues_list/queries/reorder_issues.mutation.graphql b/app/assets/javascripts/issues_list/queries/reorder_issues.mutation.graphql new file mode 100644 index 00000000000..5927e3e83c7 --- /dev/null +++ b/app/assets/javascripts/issues_list/queries/reorder_issues.mutation.graphql @@ -0,0 +1,7 @@ +mutation reorderIssues($oldIndex: Int, $newIndex: Int, $serializedVariables: String) { + reorderIssues( + oldIndex: $oldIndex + newIndex: $newIndex + serializedVariables: $serializedVariables + ) @client +} diff --git a/app/assets/javascripts/issues_list/utils.js b/app/assets/javascripts/issues_list/utils.js index 49f937cc453..1d3d07475af 100644 --- a/app/assets/javascripts/issues_list/utils.js +++ b/app/assets/javascripts/issues_list/utils.js @@ -3,12 +3,14 @@ import { BLOCKING_ISSUES_DESC, CREATED_ASC, CREATED_DESC, + defaultPageSizeParams, DUE_DATE_ASC, DUE_DATE_DESC, DUE_DATE_VALUES, filters, LABEL_PRIORITY_ASC, LABEL_PRIORITY_DESC, + largePageSizeParams, MILESTONE_DUE_ASC, MILESTONE_DUE_DESC, NORMAL_FILTER, @@ -21,6 +23,8 @@ import { SPECIAL_FILTER_VALUES, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_ITERATION, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_TYPE, UPDATED_ASC, UPDATED_DESC, URL_PARAM, @@ -35,6 +39,9 @@ import { OPERATOR_IS_NOT, } from '~/vue_shared/components/filtered_search_bar/constants'; +export const getInitialPageParams = (sortKey) => + sortKey === RELATIVE_POSITION_ASC ? largePageSizeParams : defaultPageSizeParams; + export const getSortKey = (sort) => Object.keys(urlSortParams).find((key) => urlSortParams[key] === sort); @@ -186,8 +193,17 @@ const getFilterType = (data, tokenType = '') => ? SPECIAL_FILTER : NORMAL_FILTER; -const isIterationSpecialValue = (tokenType, value) => - tokenType === TOKEN_TYPE_ITERATION && SPECIAL_FILTER_VALUES.includes(value); +const isWildcardValue = (tokenType, value) => + (tokenType === TOKEN_TYPE_ITERATION || tokenType === TOKEN_TYPE_MILESTONE) && + SPECIAL_FILTER_VALUES.includes(value); + +const requiresUpperCaseValue = (tokenType, value) => + tokenType === TOKEN_TYPE_TYPE || isWildcardValue(tokenType, value); + +const formatData = (token) => + requiresUpperCaseValue(token.type, token.value.data) + ? token.value.data.toUpperCase() + : token.value.data; export const convertToApiParams = (filterTokens) => { const params = {}; @@ -199,9 +215,7 @@ export const convertToApiParams = (filterTokens) => { const filterType = getFilterType(token.value.data, token.type); const field = filters[token.type][API_PARAM][filterType]; const obj = token.value.operator === OPERATOR_IS_NOT ? not : params; - const data = isIterationSpecialValue(token.type, token.value.data) - ? token.value.data.toUpperCase() - : token.value.data; + const data = formatData(token); Object.assign(obj, { [field]: obj[field] ? [obj[field], data].flat() : data, }); diff --git a/app/assets/javascripts/jira_connect/branches/components/new_branch_form.vue b/app/assets/javascripts/jira_connect/branches/components/new_branch_form.vue new file mode 100644 index 00000000000..66fcb8e10eb --- /dev/null +++ b/app/assets/javascripts/jira_connect/branches/components/new_branch_form.vue @@ -0,0 +1,174 @@ +<script> +import { GlFormGroup, GlButton, GlFormInput, GlForm, GlAlert } from '@gitlab/ui'; +import { + CREATE_BRANCH_ERROR_GENERIC, + CREATE_BRANCH_ERROR_WITH_CONTEXT, + I18N_NEW_BRANCH_LABEL_DROPDOWN, + I18N_NEW_BRANCH_LABEL_BRANCH, + I18N_NEW_BRANCH_LABEL_SOURCE, + I18N_NEW_BRANCH_SUBMIT_BUTTON_TEXT, +} from '../constants'; +import createBranchMutation from '../graphql/mutations/create_branch.mutation.graphql'; +import ProjectDropdown from './project_dropdown.vue'; +import SourceBranchDropdown from './source_branch_dropdown.vue'; + +const DEFAULT_ALERT_VARIANT = 'danger'; +const DEFAULT_ALERT_PARAMS = { + title: '', + message: '', + variant: DEFAULT_ALERT_VARIANT, +}; + +export default { + name: 'JiraConnectNewBranch', + components: { + GlFormGroup, + GlButton, + GlFormInput, + GlForm, + GlAlert, + ProjectDropdown, + SourceBranchDropdown, + }, + inject: ['initialBranchName'], + data() { + return { + selectedProject: null, + selectedSourceBranchName: null, + branchName: this.initialBranchName, + createBranchLoading: false, + alertParams: { + ...DEFAULT_ALERT_PARAMS, + }, + }; + }, + computed: { + selectedProjectId() { + return this.selectedProject?.id; + }, + showAlert() { + return Boolean(this.alertParams?.message); + }, + disableSubmitButton() { + return !(this.selectedProject && this.selectedSourceBranchName && this.branchName); + }, + }, + methods: { + displayAlert({ title, message, variant = DEFAULT_ALERT_VARIANT } = {}) { + this.alertParams = { + title, + message, + variant, + }; + }, + onAlertDismiss() { + this.alertParams = { + ...DEFAULT_ALERT_PARAMS, + }; + }, + onProjectSelect(project) { + this.selectedProject = project; + this.selectedSourceBranchName = null; // reset branch selection + }, + onSourceBranchSelect(branchName) { + this.selectedSourceBranchName = branchName; + }, + onError({ title, message } = {}) { + this.displayAlert({ + message, + title, + }); + }, + onSubmit() { + this.createBranch(); + }, + async createBranch() { + this.createBranchLoading = true; + + try { + const { data } = await this.$apollo.mutate({ + mutation: createBranchMutation, + variables: { + name: this.branchName, + ref: this.selectedSourceBranchName, + projectPath: this.selectedProject.fullPath, + }, + }); + const { errors } = data.createBranch; + if (errors.length > 0) { + this.onError({ + title: CREATE_BRANCH_ERROR_WITH_CONTEXT, + message: errors[0], + }); + } else { + this.$emit('success'); + } + } catch (e) { + this.onError({ + message: CREATE_BRANCH_ERROR_GENERIC, + }); + } + + this.createBranchLoading = false; + }, + }, + i18n: { + I18N_NEW_BRANCH_LABEL_DROPDOWN, + I18N_NEW_BRANCH_LABEL_BRANCH, + I18N_NEW_BRANCH_LABEL_SOURCE, + I18N_NEW_BRANCH_SUBMIT_BUTTON_TEXT, + }, +}; +</script> +<template> + <gl-form @submit.prevent="onSubmit"> + <gl-alert + v-if="showAlert" + class="gl-mb-5" + :variant="alertParams.variant" + :title="alertParams.title" + @dismiss="onAlertDismiss" + > + {{ alertParams.message }} + </gl-alert> + <gl-form-group :label="$options.i18n.I18N_NEW_BRANCH_LABEL_DROPDOWN" label-for="project-select"> + <project-dropdown + id="project-select" + :selected-project="selectedProject" + @change="onProjectSelect" + @error="onError" + /> + </gl-form-group> + + <gl-form-group + :label="$options.i18n.I18N_NEW_BRANCH_LABEL_BRANCH" + label-for="branch-name-input" + > + <gl-form-input id="branch-name-input" v-model="branchName" type="text" required /> + </gl-form-group> + + <gl-form-group + :label="$options.i18n.I18N_NEW_BRANCH_LABEL_SOURCE" + label-for="source-branch-select" + > + <source-branch-dropdown + id="source-branch-select" + :selected-project="selectedProject" + :selected-branch-name="selectedSourceBranchName" + @change="onSourceBranchSelect" + @error="onError" + /> + </gl-form-group> + + <div class="form-actions"> + <gl-button + :loading="createBranchLoading" + type="submit" + variant="confirm" + :disabled="disableSubmitButton" + > + {{ $options.i18n.I18N_NEW_BRANCH_SUBMIT_BUTTON_TEXT }} + </gl-button> + </div> + </gl-form> +</template> diff --git a/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue index c1f57be7f97..751f3e9639d 100644 --- a/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue +++ b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue @@ -41,7 +41,7 @@ export default { }; }, update(data) { - return data?.projects?.nodes.filter((project) => !project.repository.empty) ?? []; + return data?.projects?.nodes.filter((project) => !project.repository?.empty) ?? []; }, result() { this.initialProjectsLoading = false; @@ -60,7 +60,7 @@ export default { }, }, methods: { - async onProjectSelect(project) { + onProjectSelect(project) { this.$emit('change', project); }, onError({ message } = {}) { diff --git a/app/assets/javascripts/jira_connect/branches/constants.js b/app/assets/javascripts/jira_connect/branches/constants.js index 987c8d356b4..ab9d3b2c110 100644 --- a/app/assets/javascripts/jira_connect/branches/constants.js +++ b/app/assets/javascripts/jira_connect/branches/constants.js @@ -1,2 +1,25 @@ +import { __, s__ } from '~/locale'; + export const BRANCHES_PER_PAGE = 20; export const PROJECTS_PER_PAGE = 20; + +export const I18N_NEW_BRANCH_LABEL_DROPDOWN = __('Project'); +export const I18N_NEW_BRANCH_LABEL_BRANCH = __('Branch name'); +export const I18N_NEW_BRANCH_LABEL_SOURCE = __('Source branch'); +export const I18N_NEW_BRANCH_SUBMIT_BUTTON_TEXT = __('Create branch'); + +export const CREATE_BRANCH_ERROR_GENERIC = s__( + 'JiraConnect|Failed to create branch. Please try again.', +); +export const CREATE_BRANCH_ERROR_WITH_CONTEXT = s__('JiraConnect|Failed to create branch.'); + +export const I18N_PAGE_TITLE_WITH_BRANCH_NAME = s__( + 'JiraConnect|Create branch for Jira issue %{jiraIssue}', +); +export const I18N_PAGE_TITLE_DEFAULT = __('New branch'); +export const I18N_NEW_BRANCH_SUCCESS_TITLE = s__( + 'JiraConnect|New branch was successfully created.', +); +export const I18N_NEW_BRANCH_SUCCESS_MESSAGE = s__( + 'JiraConnect|You can now close this window and return to Jira.', +); diff --git a/app/assets/javascripts/jira_connect/branches/graphql/mutations/create_branch.mutation.graphql b/app/assets/javascripts/jira_connect/branches/graphql/mutations/create_branch.mutation.graphql new file mode 100644 index 00000000000..7e9cbda8317 --- /dev/null +++ b/app/assets/javascripts/jira_connect/branches/graphql/mutations/create_branch.mutation.graphql @@ -0,0 +1,6 @@ +mutation createBranch($name: String!, $projectPath: ID!, $ref: String!) { + createBranch(input: { name: $name, projectPath: $projectPath, ref: $ref }) { + clientMutationId + errors + } +} diff --git a/app/assets/javascripts/jira_connect/branches/index.js b/app/assets/javascripts/jira_connect/branches/index.js new file mode 100644 index 00000000000..95bd4f5c675 --- /dev/null +++ b/app/assets/javascripts/jira_connect/branches/index.js @@ -0,0 +1,36 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import JiraConnectNewBranchPage from '~/jira_connect/branches/pages/index.vue'; +import createDefaultClient from '~/lib/graphql'; + +Vue.use(VueApollo); + +export default async function initJiraConnectBranches() { + const el = document.querySelector('.js-jira-connect-create-branch'); + if (!el) { + return null; + } + + const { initialBranchName, successStateSvgPath } = el.dataset; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + assumeImmutableResults: true, + }, + ), + }); + + return new Vue({ + el, + apolloProvider, + provide: { + initialBranchName, + successStateSvgPath, + }, + render(createElement) { + return createElement(JiraConnectNewBranchPage); + }, + }); +} diff --git a/app/assets/javascripts/jira_connect/branches/pages/index.vue b/app/assets/javascripts/jira_connect/branches/pages/index.vue new file mode 100644 index 00000000000..d72dec6cdee --- /dev/null +++ b/app/assets/javascripts/jira_connect/branches/pages/index.vue @@ -0,0 +1,60 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { sprintf } from '~/locale'; +import NewBranchForm from '../components/new_branch_form.vue'; +import { + I18N_PAGE_TITLE_WITH_BRANCH_NAME, + I18N_PAGE_TITLE_DEFAULT, + I18N_NEW_BRANCH_SUCCESS_TITLE, + I18N_NEW_BRANCH_SUCCESS_MESSAGE, +} from '../constants'; + +export default { + components: { + GlEmptyState, + NewBranchForm, + }, + inject: ['initialBranchName', 'successStateSvgPath'], + data() { + return { + showForm: true, + }; + }, + computed: { + pageTitle() { + return this.initialBranchName + ? sprintf(this.$options.i18n.I18N_PAGE_TITLE_WITH_BRANCH_NAME, { + jiraIssue: this.initialBranchName, + }) + : this.$options.i18n.I18N_PAGE_TITLE_DEFAULT; + }, + }, + methods: { + onNewBranchFormSuccess() { + // light-weight toggle to hide the form and show the success state + this.showForm = false; + }, + }, + i18n: { + I18N_PAGE_TITLE_WITH_BRANCH_NAME, + I18N_PAGE_TITLE_DEFAULT, + I18N_NEW_BRANCH_SUCCESS_TITLE, + I18N_NEW_BRANCH_SUCCESS_MESSAGE, + }, +}; +</script> +<template> + <div> + <div class="gl-border-1 gl-border-b-solid gl-border-gray-100 gl-mb-5 gl-mt-7"> + <h1 data-testid="page-title" class="page-title">{{ pageTitle }}</h1> + </div> + + <new-branch-form v-if="showForm" @success="onNewBranchFormSuccess" /> + <gl-empty-state + v-else + :title="$options.i18n.I18N_NEW_BRANCH_SUCCESS_TITLE" + :description="$options.i18n.I18N_NEW_BRANCH_SUCCESS_MESSAGE" + :svg-path="successStateSvgPath" + /> + </div> +</template> diff --git a/app/assets/javascripts/jira_connect/.eslintrc.yml b/app/assets/javascripts/jira_connect/subscriptions/.eslintrc.yml index 053f8c6b285..053f8c6b285 100644 --- a/app/assets/javascripts/jira_connect/.eslintrc.yml +++ b/app/assets/javascripts/jira_connect/subscriptions/.eslintrc.yml diff --git a/app/assets/javascripts/jira_connect/api.js b/app/assets/javascripts/jira_connect/subscriptions/api.js index abf2c070e68..14947b6c835 100644 --- a/app/assets/javascripts/jira_connect/api.js +++ b/app/assets/javascripts/jira_connect/subscriptions/api.js @@ -1,5 +1,5 @@ import axios from 'axios'; -import { getJwt } from '~/jira_connect/utils'; +import { getJwt } from './utils'; export const addSubscription = async (addPath, namespace) => { const jwt = await getJwt(); diff --git a/app/assets/javascripts/jira_connect/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue index ff4dfb23687..413424be28d 100644 --- a/app/assets/javascripts/jira_connect/components/app.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue @@ -1,7 +1,7 @@ <script> import { GlAlert, GlButton, GlLink, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui'; import { mapState, mapMutations } from 'vuex'; -import { retrieveAlert, getLocation } from '~/jira_connect/utils'; +import { retrieveAlert, getLocation } from '~/jira_connect/subscriptions/utils'; import { __ } from '~/locale'; import { SET_ALERT } from '../store/mutation_types'; import GroupsList from './groups_list.vue'; diff --git a/app/assets/javascripts/jira_connect/components/group_item_name.vue b/app/assets/javascripts/jira_connect/subscriptions/components/group_item_name.vue index e6c172dae9e..e6c172dae9e 100644 --- a/app/assets/javascripts/jira_connect/components/group_item_name.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/group_item_name.vue diff --git a/app/assets/javascripts/jira_connect/components/groups_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/groups_list.vue index 55233bb6326..5a49d7c1a90 100644 --- a/app/assets/javascripts/jira_connect/components/groups_list.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/groups_list.vue @@ -1,7 +1,10 @@ <script> import { GlLoadingIcon, GlPagination, GlAlert, GlSearchBoxByType } from '@gitlab/ui'; -import { fetchGroups } from '~/jira_connect/api'; -import { DEFAULT_GROUPS_PER_PAGE, MINIMUM_SEARCH_TERM_LENGTH } from '~/jira_connect/constants'; +import { fetchGroups } from '~/jira_connect/subscriptions/api'; +import { + DEFAULT_GROUPS_PER_PAGE, + MINIMUM_SEARCH_TERM_LENGTH, +} from '~/jira_connect/subscriptions/constants'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import GroupsListItem from './groups_list_item.vue'; diff --git a/app/assets/javascripts/jira_connect/components/groups_list_item.vue b/app/assets/javascripts/jira_connect/subscriptions/components/groups_list_item.vue index ad046920dd1..ed7585e8a88 100644 --- a/app/assets/javascripts/jira_connect/components/groups_list_item.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/groups_list_item.vue @@ -1,8 +1,8 @@ <script> import { GlButton } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { addSubscription } from '~/jira_connect/api'; -import { persistAlert, reloadPage } from '~/jira_connect/utils'; +import { addSubscription } from '~/jira_connect/subscriptions/api'; +import { persistAlert, reloadPage } from '~/jira_connect/subscriptions/utils'; import { s__ } from '~/locale'; import GroupItemName from './group_item_name.vue'; diff --git a/app/assets/javascripts/jira_connect/components/subscriptions_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue index a606e2edbbb..7062fb370ed 100644 --- a/app/assets/javascripts/jira_connect/components/subscriptions_list.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue @@ -2,8 +2,8 @@ import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { mapMutations } from 'vuex'; -import { removeSubscription } from '~/jira_connect/api'; -import { reloadPage } from '~/jira_connect/utils'; +import { removeSubscription } from '~/jira_connect/subscriptions/api'; +import { reloadPage } from '~/jira_connect/subscriptions/utils'; import { __, s__ } from '~/locale'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { SET_ALERT } from '../store/mutation_types'; diff --git a/app/assets/javascripts/jira_connect/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js index 8dff83eabb5..8dff83eabb5 100644 --- a/app/assets/javascripts/jira_connect/constants.js +++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js diff --git a/app/assets/javascripts/jira_connect/index.js b/app/assets/javascripts/jira_connect/subscriptions/index.js index bc0d21c6c9a..f1262be0174 100644 --- a/app/assets/javascripts/jira_connect/index.js +++ b/app/assets/javascripts/jira_connect/subscriptions/index.js @@ -1,13 +1,13 @@ -import '../webpack'; +import '../../webpack'; import setConfigs from '@gitlab/ui/dist/config'; import Vue from 'vue'; -import { getLocation, sizeToParent } from '~/jira_connect/utils'; import GlFeatureFlagsPlugin from '~/vue_shared/gl_feature_flags_plugin'; import Translate from '~/vue_shared/translate'; import JiraConnectApp from './components/app.vue'; import createStore from './store'; +import { getLocation, sizeToParent } from './utils'; const store = createStore(); diff --git a/app/assets/javascripts/jira_connect/store/index.js b/app/assets/javascripts/jira_connect/subscriptions/store/index.js index de830e3891a..de830e3891a 100644 --- a/app/assets/javascripts/jira_connect/store/index.js +++ b/app/assets/javascripts/jira_connect/subscriptions/store/index.js diff --git a/app/assets/javascripts/jira_connect/store/mutation_types.js b/app/assets/javascripts/jira_connect/subscriptions/store/mutation_types.js index 15f36b824d9..15f36b824d9 100644 --- a/app/assets/javascripts/jira_connect/store/mutation_types.js +++ b/app/assets/javascripts/jira_connect/subscriptions/store/mutation_types.js diff --git a/app/assets/javascripts/jira_connect/store/mutations.js b/app/assets/javascripts/jira_connect/subscriptions/store/mutations.js index 2a25e0fe25f..2a25e0fe25f 100644 --- a/app/assets/javascripts/jira_connect/store/mutations.js +++ b/app/assets/javascripts/jira_connect/subscriptions/store/mutations.js diff --git a/app/assets/javascripts/jira_connect/store/state.js b/app/assets/javascripts/jira_connect/subscriptions/store/state.js index c807df03f00..c807df03f00 100644 --- a/app/assets/javascripts/jira_connect/store/state.js +++ b/app/assets/javascripts/jira_connect/subscriptions/store/state.js diff --git a/app/assets/javascripts/jira_connect/utils.js b/app/assets/javascripts/jira_connect/subscriptions/utils.js index ecd1a31339a..ecd1a31339a 100644 --- a/app/assets/javascripts/jira_connect/utils.js +++ b/app/assets/javascripts/jira_connect/subscriptions/utils.js diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue index a6eff743ce9..d90377029c5 100644 --- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue +++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue @@ -46,7 +46,7 @@ export default { return timeIntervalInWords(this.job.queued); }, runnerHelpUrl() { - return helpPagePath('ci/runners/index.html', { + return helpPagePath('ci/runners/configure_runners.html', { anchor: 'set-maximum-job-timeout-for-a-runner', }); }, diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue index 36b0ad43b14..1780afd39e8 100644 --- a/app/assets/javascripts/jobs/components/stages_dropdown.vue +++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue @@ -2,10 +2,12 @@ import { GlLink, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; export default { components: { CiIcon, + clipboardButton, GlDropdown, GlDropdownItem, GlLink, @@ -45,7 +47,7 @@ export default { <template> <div class="dropdown"> <div class="js-pipeline-info" data-testid="pipeline-info"> - <ci-icon :status="pipeline.details.status" class="vertical-align-middle" /> + <ci-icon :status="pipeline.details.status" /> <span class="font-weight-bold">{{ s__('Job|Pipeline') }}</span> <gl-link @@ -85,7 +87,14 @@ export default { </template> <gl-link v-else :href="pipeline.ref.path" class="link-commit ref-name">{{ pipeline.ref.name - }}</gl-link> + }}</gl-link + ><clipboard-button + :text="pipeline.ref.name" + :title="__('Copy reference')" + category="tertiary" + size="small" + data-testid="copy-source-ref-link" + /> </template> </div> diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js index a8be5d8d039..53e3dbbad0d 100644 --- a/app/assets/javascripts/jobs/store/actions.js +++ b/app/assets/javascripts/jobs/store/actions.js @@ -13,6 +13,7 @@ import { scrollUp, } from '~/lib/utils/scroll_utils'; import { __ } from '~/locale'; +import { reportToSentry } from '../utils'; import * as types from './mutation_types'; export const init = ({ dispatch }, { endpoint, logState, pagePath }) => { @@ -175,11 +176,14 @@ export const fetchTrace = ({ dispatch, state }) => dispatch('startPollingTrace'); } }) - .catch((e) => - e.response.status === httpStatusCodes.FORBIDDEN - ? dispatch('receiveTraceUnauthorizedError') - : dispatch('receiveTraceError'), - ); + .catch((e) => { + if (e.response.status === httpStatusCodes.FORBIDDEN) { + dispatch('receiveTraceUnauthorizedError'); + } else { + reportToSentry('job_actions', e); + dispatch('receiveTraceError'); + } + }); export const startPollingTrace = ({ dispatch, commit }) => { const traceTimeout = setTimeout(() => { diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js index 36391a4d433..b64734e29f6 100644 --- a/app/assets/javascripts/jobs/store/utils.js +++ b/app/assets/javascripts/jobs/store/utils.js @@ -132,7 +132,7 @@ export const logLinesParserLegacy = (lines = [], accumulator = []) => ); export const logLinesParser = (lines = [], previousTraceState = {}, prevParsedLines = []) => { - let currentLine = previousTraceState?.prevLineCount ?? 0; + let currentLineCount = previousTraceState?.prevLineCount ?? 0; let currentHeader = previousTraceState?.currentHeader; let isPreviousLineHeader = previousTraceState?.isPreviousLineHeader ?? false; const parsedLines = prevParsedLines.length > 0 ? prevParsedLines : []; @@ -141,27 +141,27 @@ export const logLinesParser = (lines = [], previousTraceState = {}, prevParsedLi for (let i = 0; i < lines.length; i += 1) { const line = lines[i]; // First run we can use the current index, later runs we have to retrieve the last number of lines - currentLine = previousTraceState?.prevLineCount ? currentLine + 1 : i + 1; + currentLineCount = previousTraceState?.prevLineCount ? currentLineCount + 1 : i + 1; if (line.section_header && !isPreviousLineHeader) { // If there's no previous line header that means we're at the root of the log isPreviousLineHeader = true; - parsedLines.push(parseHeaderLine(line, currentLine)); + parsedLines.push(parseHeaderLine(line, currentLineCount)); currentHeader = { index: parsedLines.length - 1 }; } else if (line.section_header && isPreviousLineHeader) { // If there's a current section, we can't push to the parsedLines array sectionsQueue.push(currentHeader); - currentHeader = parseHeaderLine(line, currentLine); // Let's parse the incoming header line + currentHeader = parseHeaderLine(line, currentLineCount); // Let's parse the incoming header line } else if (line.section && !line.section_duration) { // We're inside a collapsible section and want to parse a standard line if (currentHeader?.index) { // If the current section header is only an index, add the line as part of the lines // array of the current collapsible section - parsedLines[currentHeader.index].lines.push(parseLine(line, currentLine)); + parsedLines[currentHeader.index].lines.push(parseLine(line, currentLineCount)); } else { // Otherwise add it to the innermost collapsible section lines array - currentHeader.lines.push(parseLine(line, currentLine)); + currentHeader.lines.push(parseLine(line, currentLineCount)); } } else if (line.section && line.section_duration) { // NOTE: This marks the end of a section_header @@ -174,7 +174,7 @@ export const logLinesParser = (lines = [], previousTraceState = {}, prevParsedLi parsedLines[currentHeader.index].line.section_duration = line.section_duration; isPreviousLineHeader = false; currentHeader = null; - } else { + } else if (currentHeader?.isHeader) { currentHeader.line.section_duration = line.section_duration; if (previousSection && previousSection?.index) { @@ -185,9 +185,14 @@ export const logLinesParser = (lines = [], previousTraceState = {}, prevParsedLi } currentHeader = previousSection; + } else { + // On older job logs, there's no `section_header: true` response, it's just an object + // with the `section_duration` and `section` props, so we just parse it + // as a standard line + parsedLines.push(parseLine(line, currentLineCount)); } } else { - parsedLines.push(parseLine(line, currentLine)); + parsedLines.push(parseLine(line, currentLineCount)); } } @@ -197,7 +202,7 @@ export const logLinesParser = (lines = [], previousTraceState = {}, prevParsedLi isPreviousLineHeader, currentHeader, sectionsQueue, - prevLineCount: lines.length, + prevLineCount: currentLineCount, }, }; }; diff --git a/app/assets/javascripts/jobs/utils.js b/app/assets/javascripts/jobs/utils.js index 1ccecf3eb53..bb27658369f 100644 --- a/app/assets/javascripts/jobs/utils.js +++ b/app/assets/javascripts/jobs/utils.js @@ -1,3 +1,5 @@ +import * as Sentry from '@sentry/browser'; + /** * capture anything starting with http:// or https:// * https?:\/\/ @@ -10,3 +12,10 @@ */ export const linkRegex = /(https?:\/\/[^"<>()\\^`{|}\s]+[^"<>()\\^`{|}\s.,:;!?])/g; export default { linkRegex }; + +export const reportToSentry = (component, failureType) => { + Sentry.withScope((scope) => { + scope.setTag('component', component); + Sentry.captureException(failureType); + }); +}; diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js index b7cb6aa0a21..2b4dd205cf1 100644 --- a/app/assets/javascripts/lazy_loader.js +++ b/app/assets/javascripts/lazy_loader.js @@ -44,7 +44,7 @@ export default class LazyLoader { startContentObserver() { const contentNode = document.querySelector(this.observerNode) || document.querySelector('body'); - if (contentNode) { + if (contentNode && !this.mutationObserver) { this.mutationObserver = new MutationObserver(() => this.searchLazyImages()); this.mutationObserver.observe(contentNode, { diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js index 4357918672d..a026f76e51b 100644 --- a/app/assets/javascripts/lib/dompurify.js +++ b/app/assets/javascripts/lib/dompurify.js @@ -1,14 +1,14 @@ import { sanitize as dompurifySanitize, addHook } from 'dompurify'; import { getBaseURL, relativePathToAbsolute } from '~/lib/utils/url_utility'; -// Safely allow SVG <use> tags - const defaultConfig = { + // Safely allow SVG <use> tags ADD_TAGS: ['use'], + // Prevent possible XSS attacks with data-* attributes used by @rails/ujs + // See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1421 + FORBID_ATTR: ['data-remote', 'data-url', 'data-type', 'data-method'], }; -const forbiddenDataAttrs = ['data-remote', 'data-url', 'data-type', 'data-method']; - // Only icons urls from `gon` are allowed const getAllowedIconUrls = (gon = window.gon) => [gon.sprite_file_icons, gon.sprite_icons].filter(Boolean); @@ -46,19 +46,10 @@ const sanitizeSvgIcon = (node) => { removeUnsafeHref(node, 'xlink:href'); }; -const sanitizeHTMLAttributes = (node) => { - forbiddenDataAttrs.forEach((attr) => { - if (node.hasAttribute(attr)) { - node.removeAttribute(attr); - } - }); -}; - addHook('afterSanitizeAttributes', (node) => { if (node.tagName.toLowerCase() === 'use') { sanitizeSvgIcon(node); } - sanitizeHTMLAttributes(node); }); export const sanitize = (val, config = defaultConfig) => dompurifySanitize(val, config); diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 8a051041fbe..8f86fd55d6e 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -151,11 +151,24 @@ export const isMetaKey = (e) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey // 3) Middle-click or Mouse Wheel Click (e.which is 2) export const isMetaClick = (e) => e.metaKey || e.ctrlKey || e.which === 2; +/** + * Get the current computed outer height for given selector. + */ +export const getOuterHeight = (selector) => { + const element = document.querySelector(selector); + + if (!element) { + return undefined; + } + + return element.offsetHeight; +}; + export const contentTop = () => { const isDesktop = breakpointInstance.isDesktop(); const heightCalculators = [ - () => $('#js-peek').outerHeight(), - () => $('.navbar-gitlab').outerHeight(), + () => getOuterHeight('#js-peek'), + () => getOuterHeight('.navbar-gitlab'), ({ desktop }) => { const container = document.querySelector('.line-resolve-all-container'); let size = 0; @@ -166,14 +179,14 @@ export const contentTop = () => { return size; }, - () => $('.merge-request-tabs').outerHeight(), - () => $('.js-diff-files-changed').outerHeight(), + () => getOuterHeight('.merge-request-tabs'), + () => getOuterHeight('.js-diff-files-changed'), ({ desktop }) => { const diffsTabIsActive = window.mrTabs?.currentAction === 'diffs'; let size; if (desktop && diffsTabIsActive) { - size = $('.diff-file .file-title-flex-parent:visible').outerHeight(); + size = getOuterHeight('.diff-file .file-title-flex-parent:not([style="display:none"])'); } return size; @@ -182,7 +195,7 @@ export const contentTop = () => { let size; if (desktop) { - size = $('.mr-version-controls').outerHeight(); + size = getOuterHeight('.mr-version-controls'); } return size; diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 7922ff22a70..e9772232eaf 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -474,19 +474,17 @@ export function queryToObject(query, { gatherArrays = false, legacySpacesDecode } const decodedValue = legacySpacesDecode ? decodeURIComponent(value) : decodeUrlParameter(value); + const decodedKey = legacySpacesDecode ? decodeURIComponent(key) : decodeUrlParameter(key); - if (gatherArrays && key.endsWith('[]')) { - const decodedKey = legacySpacesDecode - ? decodeURIComponent(key.slice(0, -2)) - : decodeUrlParameter(key.slice(0, -2)); + if (gatherArrays && decodedKey.endsWith('[]')) { + const decodedArrayKey = decodedKey.slice(0, -2); - if (!Array.isArray(accumulator[decodedKey])) { - accumulator[decodedKey] = []; + if (!Array.isArray(accumulator[decodedArrayKey])) { + accumulator[decodedArrayKey] = []; } - accumulator[decodedKey].push(decodedValue); - } else { - const decodedKey = legacySpacesDecode ? decodeURIComponent(key) : decodeUrlParameter(key); + accumulator[decodedArrayKey].push(decodedValue); + } else { accumulator[decodedKey] = decodedValue; } diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 5c14000a2aa..1aaefcaa13b 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -27,7 +27,6 @@ import { getLocationHash, visitUrl } from './lib/utils/url_utility'; import initFeatureHighlight from './feature_highlight'; import LazyLoader from './lazy_loader'; import initLogoAnimation from './logo'; -import initFrequentItemDropdowns from './frequent_items'; import initBreadcrumbs from './breadcrumb'; import initPersistentUserCallouts from './persistent_user_callouts'; import { initUserTracking, initDefaultTrackers } from './tracking'; @@ -36,7 +35,6 @@ import GlFieldErrors from './gl_field_errors'; import initUserPopovers from './user_popovers'; import initBroadcastNotifications from './broadcast_notification'; import { initTopNav } from './nav'; -import navEventHub, { EVENT_RESPONSIVE_TOGGLE } from './nav/event_hub'; import 'ee_else_ce/main_ee'; @@ -92,7 +90,6 @@ function deferredInitialisation() { initServicePingConsent(); initUserPopovers(); initBroadcastNotifications(); - initFrequentItemDropdowns(); initPersistentUserCallouts(); initDefaultTrackers(); initFeatureHighlight(); @@ -133,138 +130,132 @@ function deferredInitialisation() { setTimeout(() => $body.addClass('page-initialised'), 1000); } -document.addEventListener('DOMContentLoaded', () => { - const $body = $('body'); - const $document = $(document); - const bootstrapBreakpoint = bp.getBreakpointSize(); - - initUserTracking(); - initLayoutNav(); - initAlertHandler(); - - // Set the default path for all cookies to GitLab's root directory - Cookies.defaults.path = gon.relative_url_root || '/'; - - // `hashchange` is not triggered when link target is already in window.location - $body.on('click', 'a[href^="#"]', function clickHashLinkCallback() { - const href = this.getAttribute('href'); - if (href.substr(1) === getLocationHash()) { - setTimeout(handleLocationHash, 1); - } - }); +const $body = $('body'); +const $document = $(document); +const bootstrapBreakpoint = bp.getBreakpointSize(); + +initUserTracking(); +initLayoutNav(); +initAlertHandler(); + +// Set the default path for all cookies to GitLab's root directory +Cookies.defaults.path = gon.relative_url_root || '/'; - /** - * TODO: Apparently we are collapsing the right sidebar on certain screensizes per default - * except on issue board pages. Why can't we do it with CSS? - * - * Proposal: Expose a global sidebar API, which we could import wherever we are manipulating - * the visibility of the sidebar. - * - * Quick fix: Get rid of jQuery for this implementation - */ - const isBoardsPage = /(projects|groups):boards:show/.test(document.body.dataset.page); - if (!isBoardsPage && (bootstrapBreakpoint === 'sm' || bootstrapBreakpoint === 'xs')) { - const $rightSidebar = $('aside.right-sidebar'); - const $layoutPage = $('.layout-page'); - - if ($rightSidebar.length > 0) { - $rightSidebar.removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); - $layoutPage.removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); - } else { - $layoutPage.removeClass('right-sidebar-expanded right-sidebar-collapsed'); - } +// `hashchange` is not triggered when link target is already in window.location +$body.on('click', 'a[href^="#"]', function clickHashLinkCallback() { + const href = this.getAttribute('href'); + if (href.substr(1) === getLocationHash()) { + setTimeout(handleLocationHash, 1); } +}); - // prevent default action for disabled buttons - $('.btn').click(function clickDisabledButtonCallback(e) { - if ($(this).hasClass('disabled')) { - e.preventDefault(); - e.stopImmediatePropagation(); - return false; - } +/** + * TODO: Apparently we are collapsing the right sidebar on certain screensizes per default + * except on issue board pages. Why can't we do it with CSS? + * + * Proposal: Expose a global sidebar API, which we could import wherever we are manipulating + * the visibility of the sidebar. + * + * Quick fix: Get rid of jQuery for this implementation + */ +const isBoardsPage = /(projects|groups):boards:show/.test(document.body.dataset.page); +if (!isBoardsPage && (bootstrapBreakpoint === 'sm' || bootstrapBreakpoint === 'xs')) { + const $rightSidebar = $('aside.right-sidebar'); + const $layoutPage = $('.layout-page'); + + if ($rightSidebar.length > 0) { + $rightSidebar.removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); + $layoutPage.removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); + } else { + $layoutPage.removeClass('right-sidebar-expanded right-sidebar-collapsed'); + } +} - return true; - }); +// prevent default action for disabled buttons +$('.btn').click(function clickDisabledButtonCallback(e) { + if ($(this).hasClass('disabled')) { + e.preventDefault(); + e.stopImmediatePropagation(); + return false; + } - localTimeAgo(document.querySelectorAll('abbr.timeago, .js-timeago'), true); - - /** - * This disables form buttons while a form is submitting - * We do not difinitively know all of the places where this is used - * - * TODO: Defer execution, migrate to behaviors, and add sentry logging - */ - $body.on('ajax:complete, ajax:beforeSend, submit', 'form', function ajaxCompleteCallback(e) { - const $buttons = $('[type="submit"], .js-disable-on-submit', this).not('.js-no-auto-disable'); - switch (e.type) { - case 'ajax:beforeSend': - case 'submit': - return $buttons.disable(); - default: - return $buttons.enable(); - } - }); + return true; +}); - $('.navbar-toggler').on('click', () => { - // The order is important. The `menu-expanded` is used as a source of truth for now. - // This can be simplified when the :combined_menu feature flag is removed. - // https://gitlab.com/gitlab-org/gitlab/-/issues/333180 - $('.header-content').toggleClass('menu-expanded'); - navEventHub.$emit(EVENT_RESPONSIVE_TOGGLE); - }); +localTimeAgo(document.querySelectorAll('abbr.timeago, .js-timeago'), true); + +/** + * This disables form buttons while a form is submitting + * We do not difinitively know all of the places where this is used + * + * TODO: Defer execution, migrate to behaviors, and add sentry logging + */ +$body.on('ajax:complete, ajax:beforeSend, submit', 'form', function ajaxCompleteCallback(e) { + const $buttons = $('[type="submit"], .js-disable-on-submit', this).not('.js-no-auto-disable'); + switch (e.type) { + case 'ajax:beforeSend': + case 'submit': + return $buttons.disable(); + default: + return $buttons.enable(); + } +}); - /** - * Show suppressed commit diff - * - * TODO: Move to commit diff pages - */ - $document.on('click', '.diff-content .js-show-suppressed-diff', function showDiffCallback() { - const $container = $(this).parent(); - $container.next('table').show(); - $container.remove(); - }); +$('.navbar-toggler').on('click', () => { + document.body.classList.toggle('top-nav-responsive-open'); +}); - // Show/hide comments on diff - $body.on('click', '.js-toggle-diff-comments', function toggleDiffCommentsCallback(e) { - const $this = $(this); - const notesHolders = $this.closest('.diff-file').find('.notes_holder'); +/** + * Show suppressed commit diff + * + * TODO: Move to commit diff pages + */ +$document.on('click', '.diff-content .js-show-suppressed-diff', function showDiffCallback() { + const $container = $(this).parent(); + $container.next('table').show(); + $container.remove(); +}); - e.preventDefault(); +// Show/hide comments on diff +$body.on('click', '.js-toggle-diff-comments', function toggleDiffCommentsCallback(e) { + const $this = $(this); + const notesHolders = $this.closest('.diff-file').find('.notes_holder'); - $this.toggleClass('selected'); + e.preventDefault(); - if ($this.hasClass('active')) { - notesHolders.show().find('.hide, .content').show(); - } else { - notesHolders.hide().find('.content').hide(); - } + $this.toggleClass('selected'); - $(document).trigger('toggle.comments'); - }); + if ($this.hasClass('active')) { + notesHolders.show().find('.hide, .content').show(); + } else { + notesHolders.hide().find('.content').hide(); + } - $('form.filter-form').on('submit', function filterFormSubmitCallback(event) { - const link = document.createElement('a'); - link.href = this.action; + $(document).trigger('toggle.comments'); +}); - const action = `${this.action}${link.search === '' ? '?' : '&'}`; +$('form.filter-form').on('submit', function filterFormSubmitCallback(event) { + const link = document.createElement('a'); + link.href = this.action; - event.preventDefault(); - // eslint-disable-next-line no-jquery/no-serialize - visitUrl(`${action}${$(this).serialize()}`); - }); + const action = `${this.action}${link.search === '' ? '?' : '&'}`; - const flashContainer = document.querySelector('.flash-container'); + event.preventDefault(); + // eslint-disable-next-line no-jquery/no-serialize + visitUrl(`${action}${$(this).serialize()}`); +}); - if (flashContainer && flashContainer.children.length) { - flashContainer - .querySelectorAll('.flash-alert, .flash-notice, .flash-success') - .forEach((flashEl) => { - removeFlashClickListener(flashEl); - }); - } +const flashContainer = document.querySelector('.flash-container'); - // initialize field errors - $('.gl-show-field-errors').each((i, form) => new GlFieldErrors(form)); +if (flashContainer && flashContainer.children.length) { + flashContainer + .querySelectorAll('.flash-alert, .flash-notice, .flash-success') + .forEach((flashEl) => { + removeFlashClickListener(flashEl); + }); +} - requestIdleCallback(deferredInitialisation); -}); +// initialize field errors +$('.gl-show-field-errors').each((i, form) => new GlFieldErrors(form)); + +requestIdleCallback(deferredInitialisation); diff --git a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue index a477aedd233..665e8ee69f7 100644 --- a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue @@ -1,6 +1,6 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { mapState } from 'vuex'; +import { mapActions, mapState } from 'vuex'; export default { name: 'RemoveMemberButton', @@ -45,7 +45,7 @@ export default { oncallSchedules: { type: Object, required: false, - default: () => {}, + default: () => ({}), }, }, computed: { @@ -54,30 +54,35 @@ export default { return state[this.namespace].memberPath; }, }), - computedMemberPath() { - return this.memberPath.replace(':id', this.memberId); - }, - stringifiedSchedules() { - return JSON.stringify(this.oncallSchedules); + modalData() { + return { + isAccessRequest: this.isAccessRequest, + isInvite: this.isInvite, + memberPath: this.memberPath.replace(':id', this.memberId), + memberType: this.memberType, + message: this.message, + oncallSchedules: this.oncallSchedules, + }; }, }, + methods: { + ...mapActions({ + showRemoveMemberModal(dispatch, payload) { + return dispatch(`${this.namespace}/showRemoveMemberModal`, payload); + }, + }), + }, }; </script> <template> <gl-button - v-gl-tooltip.hover - class="js-remove-member-button" + v-gl-tooltip variant="danger" :title="title" :aria-label="title" :icon="icon" - :data-member-path="computedMemberPath" - :data-member-type="memberType" - :data-is-access-request="isAccessRequest" - :data-is-invite="isInvite" - :data-message="message" - :data-oncall-schedules="stringifiedSchedules" data-qa-selector="delete_member_button" + @click="showRemoveMemberModal(modalData)" /> </template> diff --git a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue index 33d86dec767..e9329fb1d88 100644 --- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue +++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue @@ -1,7 +1,12 @@ <script> import { GlFilteredSearchToken } from '@gitlab/ui'; import { mapState } from 'vuex'; -import { getParameterByName, setUrlParams, queryToObject } from '~/lib/utils/url_utility'; +import { + getParameterByName, + setUrlParams, + queryToObject, + redirectTo, +} from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import { SEARCH_TOKEN_TYPE, @@ -122,14 +127,16 @@ export default { const sortParamValue = getParameterByName(SORT_QUERY_PARAM_NAME); const activeTabParamValue = getParameterByName(ACTIVE_TAB_QUERY_PARAM_NAME); - window.location.href = setUrlParams( - { - ...params, - ...(sortParamValue && { [SORT_QUERY_PARAM_NAME]: sortParamValue }), - ...(activeTabParamValue && { [ACTIVE_TAB_QUERY_PARAM_NAME]: activeTabParamValue }), - }, - window.location.href, - true, + redirectTo( + setUrlParams( + { + ...params, + ...(sortParamValue && { [SORT_QUERY_PARAM_NAME]: sortParamValue }), + ...(activeTabParamValue && { [ACTIVE_TAB_QUERY_PARAM_NAME]: activeTabParamValue }), + }, + window.location.href, + true, + ), ); }, }, diff --git a/app/assets/javascripts/members/components/members_tabs.vue b/app/assets/javascripts/members/components/members_tabs.vue index 7c21e33d892..ee4743010cf 100644 --- a/app/assets/javascripts/members/components/members_tabs.vue +++ b/app/assets/javascripts/members/components/members_tabs.vue @@ -1,8 +1,7 @@ <script> -import { GlTabs, GlTab, GlBadge } from '@gitlab/ui'; +import { GlTabs, GlTab, GlBadge, GlButton } from '@gitlab/ui'; import { mapState } from 'vuex'; -// eslint-disable-next-line import/no-deprecated -import { urlParamsToObject } from '~/lib/utils/url_utility'; +import { queryToObject } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import { MEMBER_TYPES, TAB_QUERY_PARAM_VALUES, ACTIVE_TAB_QUERY_PARAM_NAME } from '../constants'; import MembersApp from './app.vue'; @@ -36,8 +35,8 @@ export default { queryParamValue: TAB_QUERY_PARAM_VALUES.accessRequest, }, ], - components: { MembersApp, GlTabs, GlTab, GlBadge }, - inject: ['canManageMembers'], + components: { MembersApp, GlTabs, GlTab, GlBadge, GlButton }, + inject: ['canManageMembers', 'canExportMembers', 'exportCsvPath'], data() { return { selectedTabIndex: 0, @@ -59,8 +58,7 @@ export default { }, }), urlParams() { - // eslint-disable-next-line import/no-deprecated - return Object.keys(urlParamsToObject(window.location.search)); + return Object.keys(queryToObject(window.location.search, { gatherArrays: true })); }, activeTabIndexCalculatedFromUrlParams() { return this.$options.TABS.findIndex(({ namespace }) => { @@ -123,5 +121,15 @@ export default { <members-app :namespace="tab.namespace" :tab-query-param-value="tab.queryParamValue" /> </gl-tab> </template> + <template #tabs-end> + <gl-button + v-if="canExportMembers" + class="gl-align-self-center gl-ml-auto" + icon="export" + :href="exportCsvPath" + > + {{ __('Export as CSV') }} + </gl-button> + </template> </gl-tabs> </template> diff --git a/app/assets/javascripts/vue_shared/components/remove_member_modal.vue b/app/assets/javascripts/members/components/modals/remove_member_modal.vue index 07272a5b8d6..00b6ebf9a73 100644 --- a/app/assets/javascripts/vue_shared/components/remove_member_modal.vue +++ b/app/assets/javascripts/members/components/modals/remove_member_modal.vue @@ -1,7 +1,6 @@ <script> import { GlFormCheckbox, GlModal } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; -import { parseBoolean } from '~/lib/utils/common_utils'; +import { mapActions, mapState } from 'vuex'; import csrf from '~/lib/utils/csrf'; import { s__, __ } from '~/locale'; import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; @@ -16,20 +15,33 @@ export default { GlModal, OncallSchedulesList, }, - data() { - return { - modalData: {}, - }; - }, + inject: ['namespace'], computed: { - isAccessRequest() { - return parseBoolean(this.modalData.isAccessRequest); - }, - isInvite() { - return parseBoolean(this.modalData.isInvite); - }, + ...mapState({ + isAccessRequest(state) { + return state[this.namespace].removeMemberModalData.isAccessRequest; + }, + isInvite(state) { + return state[this.namespace].removeMemberModalData.isInvite; + }, + memberPath(state) { + return state[this.namespace].removeMemberModalData.memberPath; + }, + memberType(state) { + return state[this.namespace].removeMemberModalData.memberType; + }, + message(state) { + return state[this.namespace].removeMemberModalData.message; + }, + oncallSchedules(state) { + return state[this.namespace].removeMemberModalData.oncallSchedules ?? {}; + }, + removeMemberModalVisible(state) { + return state[this.namespace].removeMemberModalVisible; + }, + }), isGroupMember() { - return this.modalData.memberType === 'GroupMember'; + return this.memberType === 'GroupMember'; }, actionText() { if (this.isAccessRequest) { @@ -54,29 +66,13 @@ export default { isPartOfOncallSchedules() { return !this.isAccessRequest && this.oncallSchedules.schedules?.length; }, - oncallSchedules() { - try { - return JSON.parse(this.modalData.oncallSchedules); - } catch (e) { - Sentry.captureException(e); - } - return {}; - }, - }, - mounted() { - document.addEventListener('click', this.handleClick); - }, - beforeDestroy() { - document.removeEventListener('click', this.handleClick); }, methods: { - handleClick(event) { - const removeButton = event.target.closest('.js-remove-member-button'); - if (removeButton) { - this.modalData = removeButton.dataset; - this.$refs.modal.show(); - } - }, + ...mapActions({ + hideRemoveMemberModal(dispatch) { + return dispatch(`${this.namespace}/hideRemoveMemberModal`); + }, + }), submitForm() { this.$refs.form.submit(); }, @@ -91,11 +87,13 @@ export default { :action-cancel="$options.actionCancel" :action-primary="actionPrimary" :title="actionText" + :visible="removeMemberModalVisible" data-qa-selector="remove_member_modal_content" @primary="submitForm" + @hide="hideRemoveMemberModal" > - <form ref="form" :action="modalData.memberPath" method="post"> - <p data-testid="modal-message">{{ modalData.message }}</p> + <form ref="form" :action="memberPath" method="post"> + <p>{{ message }}</p> <oncall-schedules-list v-if="isPartOfOncallSchedules" diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index b9c80edbc49..debc3fc31f6 100644 --- a/app/assets/javascripts/members/components/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -7,6 +7,7 @@ import { mergeUrlParams } from '~/lib/utils/url_utility'; import initUserPopovers from '~/user_popovers'; import { FIELDS, ACTIVE_TAB_QUERY_PARAM_NAME } from '../../constants'; import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue'; +import RemoveMemberModal from '../modals/remove_member_modal.vue'; import CreatedAt from './created_at.vue'; import ExpirationDatepicker from './expiration_datepicker.vue'; import ExpiresAt from './expires_at.vue'; @@ -29,6 +30,7 @@ export default { MemberActionButtons, RoleDropdown, RemoveGroupLinkModal, + RemoveMemberModal, ExpirationDatepicker, LdapOverrideConfirmationModal: () => import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'), @@ -225,6 +227,7 @@ export default { align="center" /> <remove-group-link-modal /> + <remove-member-modal /> <ldap-override-confirmation-modal /> </div> </template> diff --git a/app/assets/javascripts/members/index.js b/app/assets/javascripts/members/index.js index 2ed0958d1dc..510e89240f4 100644 --- a/app/assets/javascripts/members/index.js +++ b/app/assets/javascripts/members/index.js @@ -14,7 +14,13 @@ export const initMembersApp = (el, options) => { Vue.use(Vuex); Vue.use(GlToast); - const { sourceId, canManageMembers, ...vuexStoreAttributes } = parseDataAttributes(el); + const { + sourceId, + canManageMembers, + canExportMembers, + exportCsvPath, + ...vuexStoreAttributes + } = parseDataAttributes(el); const modules = Object.keys(MEMBER_TYPES).reduce((accumulator, namespace) => { const namespacedOptions = options[namespace]; @@ -54,6 +60,8 @@ export const initMembersApp = (el, options) => { currentUserId: gon.current_user_id || null, sourceId, canManageMembers, + canExportMembers, + exportCsvPath, }, render: (createElement) => createElement('members-tabs'), }); diff --git a/app/assets/javascripts/members/store/actions.js b/app/assets/javascripts/members/store/actions.js index 7b191dd85d0..712f0d6caa7 100644 --- a/app/assets/javascripts/members/store/actions.js +++ b/app/assets/javascripts/members/store/actions.js @@ -25,6 +25,14 @@ export const hideRemoveGroupLinkModal = ({ commit }) => { commit(types.HIDE_REMOVE_GROUP_LINK_MODAL); }; +export const showRemoveMemberModal = ({ commit }, modalData) => { + commit(types.SHOW_REMOVE_MEMBER_MODAL, modalData); +}; + +export const hideRemoveMemberModal = ({ commit }) => { + commit(types.HIDE_REMOVE_MEMBER_MODAL); +}; + export const updateMemberExpiration = async ({ state, commit }, { memberId, expiresAt }) => { try { await axios.put( diff --git a/app/assets/javascripts/members/store/mutation_types.js b/app/assets/javascripts/members/store/mutation_types.js index 77307aa745b..5fa75725552 100644 --- a/app/assets/javascripts/members/store/mutation_types.js +++ b/app/assets/javascripts/members/store/mutation_types.js @@ -8,3 +8,6 @@ export const HIDE_ERROR = 'HIDE_ERROR'; export const SHOW_REMOVE_GROUP_LINK_MODAL = 'SHOW_REMOVE_GROUP_LINK_MODAL'; export const HIDE_REMOVE_GROUP_LINK_MODAL = 'HIDE_REMOVE_GROUP_LINK_MODAL'; + +export const SHOW_REMOVE_MEMBER_MODAL = 'SHOW_REMOVE_MEMBER_MODAL'; +export const HIDE_REMOVE_MEMBER_MODAL = 'HIDE_REMOVE_MEMBER_MODAL'; diff --git a/app/assets/javascripts/members/store/mutations.js b/app/assets/javascripts/members/store/mutations.js index f4aac1571d6..b4cf9f3480f 100644 --- a/app/assets/javascripts/members/store/mutations.js +++ b/app/assets/javascripts/members/store/mutations.js @@ -47,4 +47,11 @@ export default { [types.HIDE_REMOVE_GROUP_LINK_MODAL](state) { state.removeGroupLinkModalVisible = false; }, + [types.SHOW_REMOVE_MEMBER_MODAL](state, modalData) { + state.removeMemberModalData = modalData; + state.removeMemberModalVisible = true; + }, + [types.HIDE_REMOVE_MEMBER_MODAL](state) { + state.removeMemberModalVisible = false; + }, }; diff --git a/app/assets/javascripts/members/store/state.js b/app/assets/javascripts/members/store/state.js index 5415b1c5f25..c233a660840 100644 --- a/app/assets/javascripts/members/store/state.js +++ b/app/assets/javascripts/members/store/state.js @@ -20,4 +20,6 @@ export default ({ errorMessage: '', removeGroupLinkModalVisible: false, groupLinkToRemove: null, + removeMemberModalData: {}, + removeMemberModalVisible: false, }); diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 1d1c0a23fab..14e5e96d7b0 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -82,9 +82,9 @@ export default class MergeRequestTabs { this.mergeRequestTabPanes && this.mergeRequestTabPanes.querySelectorAll ? this.mergeRequestTabPanes.querySelectorAll('.tab-pane') : null; - const navbar = document.querySelector('.navbar-gitlab'); - const peek = document.getElementById('js-peek'); - const paddingTop = 16; + this.navbar = document.querySelector('.navbar-gitlab'); + this.peek = document.getElementById('js-peek'); + this.paddingTop = 16; this.commitsTab = document.querySelector('.tab-content .commits.tab-pane'); @@ -99,15 +99,6 @@ export default class MergeRequestTabs { this.setCurrentAction = this.setCurrentAction.bind(this); this.tabShown = this.tabShown.bind(this); this.clickTab = this.clickTab.bind(this); - this.stickyTop = navbar ? navbar.offsetHeight - paddingTop : 0; - - if (peek) { - this.stickyTop += peek.offsetHeight; - } - - if (this.mergeRequestTabs) { - this.stickyTop += this.mergeRequestTabs.offsetHeight; - } if (stubLocation) { location = stubLocation; @@ -520,4 +511,18 @@ export default class MergeRequestTabs { } }, 0); } + + get stickyTop() { + let stickyTop = this.navbar ? this.navbar.offsetHeight : 0; + + if (this.peek) { + stickyTop += this.peek.offsetHeight; + } + + if (this.mergeRequestTabs) { + stickyTop += this.mergeRequestTabs.offsetHeight; + } + + return stickyTop; + } } diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue index b786d015f3b..446c6b52602 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue @@ -404,21 +404,16 @@ export default { --> <gl-dropdown v-gl-tooltip + icon="ellipsis_v" + :text="__('More actions')" + :text-sr-only="true" toggle-class="gl-px-3!" no-caret data-qa-selector="prometheus_widgets_dropdown" right :title="__('More actions')" > - <template #button-content> - <gl-icon class="gl-mr-0!" name="ellipsis_v" /> - </template> - <gl-dropdown-item - v-if="expandBtnAvailable" - ref="expandBtn" - :href="clipboardText" - @click.prevent="onExpand" - > + <gl-dropdown-item v-if="expandBtnAvailable" ref="expandBtn" @click.prevent="onExpand"> {{ s__('Metrics|Expand panel') }} </gl-dropdown-item> <gl-dropdown-item diff --git a/app/assets/javascripts/nav/components/responsive_app.vue b/app/assets/javascripts/nav/components/responsive_app.vue index d601586a3f8..68a39f862fc 100644 --- a/app/assets/javascripts/nav/components/responsive_app.vue +++ b/app/assets/javascripts/nav/components/responsive_app.vue @@ -2,8 +2,7 @@ import { FREQUENT_ITEMS_PROJECTS, FREQUENT_ITEMS_GROUPS } from '~/frequent_items/constants'; import { BV_DROPDOWN_SHOW, BV_DROPDOWN_HIDE } from '~/lib/utils/constants'; import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue'; -import eventHub, { EVENT_RESPONSIVE_TOGGLE } from '../event_hub'; -import { resetMenuItemsActive, hasMenuExpanded } from '../utils'; +import { resetMenuItemsActive } from '../utils'; import ResponsiveHeader from './responsive_header.vue'; import ResponsiveHome from './responsive_home.vue'; import TopNavContainerView from './top_nav_container_view.vue'; @@ -33,25 +32,14 @@ export default { }, }, created() { - eventHub.$on(EVENT_RESPONSIVE_TOGGLE, this.updateResponsiveOpen); this.$root.$on(BV_DROPDOWN_SHOW, this.showMobileOverlay); this.$root.$on(BV_DROPDOWN_HIDE, this.hideMobileOverlay); - - this.updateResponsiveOpen(); }, beforeDestroy() { - eventHub.$off(EVENT_RESPONSIVE_TOGGLE, this.onToggle); this.$root.$off(BV_DROPDOWN_SHOW, this.showMobileOverlay); this.$root.$off(BV_DROPDOWN_HIDE, this.hideMobileOverlay); }, methods: { - updateResponsiveOpen() { - if (hasMenuExpanded()) { - document.body.classList.add('top-nav-responsive-open'); - } else { - document.body.classList.remove('top-nav-responsive-open'); - } - }, onMenuItemClick({ view }) { if (view) { this.activeView = view; diff --git a/app/assets/javascripts/nav/event_hub.js b/app/assets/javascripts/nav/event_hub.js deleted file mode 100644 index 2c8b1371fe3..00000000000 --- a/app/assets/javascripts/nav/event_hub.js +++ /dev/null @@ -1,5 +0,0 @@ -import eventHubFactory from '~/helpers/event_hub_factory'; - -export const EVENT_RESPONSIVE_TOGGLE = 'top-nav-responsive-toggle'; - -export default eventHubFactory(); diff --git a/app/assets/javascripts/nav/index.js b/app/assets/javascripts/nav/index.js index 86d6b42e4ea..abd537d2c9a 100644 --- a/app/assets/javascripts/nav/index.js +++ b/app/assets/javascripts/nav/index.js @@ -1,4 +1,7 @@ -// With combined_menu feature flag, there's a benefit to splitting up the import +// TODO: With the combined_menu feature flag removed, there's likely a better +// way to slice up the async import (i.e., include trigger in main bundle, but +// async import subviews. Don't do this at the cost of UX). +// See https://gitlab.com/gitlab-org/gitlab/-/issues/336042 const importModule = () => import(/* webpackChunkName: 'top_nav' */ './mount'); const tryMountTopNav = async () => { diff --git a/app/assets/javascripts/nav/utils/has_menu_expanded.js b/app/assets/javascripts/nav/utils/has_menu_expanded.js deleted file mode 100644 index 5f126bbdf76..00000000000 --- a/app/assets/javascripts/nav/utils/has_menu_expanded.js +++ /dev/null @@ -1,2 +0,0 @@ -export const hasMenuExpanded = () => - Boolean(document.querySelector('.header-content.menu-expanded')); diff --git a/app/assets/javascripts/nav/utils/index.js b/app/assets/javascripts/nav/utils/index.js index 4fa3d0910da..6d93818f0d3 100644 --- a/app/assets/javascripts/nav/utils/index.js +++ b/app/assets/javascripts/nav/utils/index.js @@ -1,2 +1 @@ -export * from './has_menu_expanded'; export * from './reset_menu_items_active'; diff --git a/app/assets/javascripts/notes/components/comment_field_layout.vue b/app/assets/javascripts/notes/components/comment_field_layout.vue index 47d14783d5d..9638c20e28c 100644 --- a/app/assets/javascripts/notes/components/comment_field_layout.vue +++ b/app/assets/javascripts/notes/components/comment_field_layout.vue @@ -14,6 +14,11 @@ export default { type: Object, required: true, }, + noteIsConfidential: { + type: Boolean, + required: false, + default: false, + }, noteableType: { type: String, required: false, @@ -38,6 +43,9 @@ export default { emailParticipants() { return this.noteableData.issue_email_participants?.map(({ email }) => email) || []; }, + showEmailParticipantsWarning() { + return this.emailParticipants.length && !this.noteIsConfidential; + }, }, }; </script> @@ -61,7 +69,7 @@ export default { /> <slot></slot> <email-participants-warning - v-if="emailParticipants.length" + v-if="showEmailParticipantsWarning" class="gl-border-t-1 gl-border-t-solid gl-border-t-gray-100 gl-rounded-base gl-rounded-top-left-none! gl-rounded-top-right-none!" :emails="emailParticipants" /> diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 9504ed78778..2ebebd76e1e 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -380,6 +380,7 @@ export default { <comment-field-layout :with-alert-container="true" :noteable-data="getNoteableData" + :note-is-confidential="noteIsConfidential" :noteable-type="noteableType" > <markdown-field diff --git a/app/assets/javascripts/notes/components/discussion_resolve_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_button.vue index e060a6affd4..b1aee19d5b2 100644 --- a/app/assets/javascripts/notes/components/discussion_resolve_button.vue +++ b/app/assets/javascripts/notes/components/discussion_resolve_button.vue @@ -21,7 +21,7 @@ export default { </script> <template> - <gl-button :loading="isResolving" class="ml-sm-2" @click="$emit('onClick')"> + <gl-button :loading="isResolving" class="gl-xs-w-full ml-sm-2" @click="$emit('onClick')"> {{ buttonTitle }} </gl-button> </template> diff --git a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue index 5f429cbf462..9119d319d72 100644 --- a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue +++ b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue @@ -29,7 +29,7 @@ export default { :href="url" :title="$options.i18n.buttonLabel" :aria-label="$options.i18n.buttonLabel" - class="new-issue-for-discussion discussion-create-issue-btn" + class="new-issue-for-discussion discussion-create-issue-btn gl-xs-w-full" icon="issue-new" /> </div> diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index 8c5d81c0cc9..9864e91c009 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -115,11 +115,11 @@ export default { renderGFM() { $(this.$refs['note-body']).renderGFM(); }, - handleFormUpdate(note, parentElement, callback, resolveDiscussion) { - this.$emit('handleFormUpdate', note, parentElement, callback, resolveDiscussion); + handleFormUpdate(noteText, parentElement, callback, resolveDiscussion) { + this.$emit('handleFormUpdate', { noteText, parentElement, callback, resolveDiscussion }); }, formCancelHandler(shouldConfirm, isDirty) { - this.$emit('cancelForm', shouldConfirm, isDirty); + this.$emit('cancelForm', { shouldConfirm, isDirty }); }, applySuggestion({ suggestionId, flashContainer, callback = () => {}, message }) { const { discussion_id: discussionId, id: noteId } = this.note; diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 4ce81219f11..f2336e1b6f5 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -326,7 +326,10 @@ export default { ></div> <div class="flash-container timeline-content"></div> <form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form"> - <comment-field-layout :noteable-data="getNoteableData"> + <comment-field-layout + :noteable-data="getNoteableData" + :note-is-confidential="discussion.confidential" + > <markdown-field :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 5ea431224ce..3c6ed0a8aac 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -263,7 +263,7 @@ export default { this.$refs.noteBody.resetAutoSave(); this.$emit('updateSuccess'); }, - formUpdateHandler(noteText, parentElement, callback, resolveDiscussion) { + formUpdateHandler({ noteText, callback, resolveDiscussion }) { const position = { ...this.note.position, }; @@ -329,7 +329,7 @@ export default { } }); }, - formCancelHandler(shouldConfirm, isDirty) { + formCancelHandler({ shouldConfirm, isDirty }) { if (shouldConfirm && isDirty) { // eslint-disable-next-line no-alert if (!window.confirm(__('Are you sure you want to cancel editing this comment?'))) return; @@ -392,6 +392,7 @@ export default { :img-src="author.avatar_url" :img-alt="author.name" :img-size="40" + lazy > <template #avatar-badge> <slot name="avatar-badge"></slot> diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index e4241669fbc..2ce60976adb 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -5,9 +5,12 @@ import initSortDiscussions from './sort_discussions'; import { store } from './stores'; import initTimelineToggle from './timeline'; -const el = document.getElementById('js-vue-notes'); +export default () => { + const el = document.getElementById('js-vue-notes'); + if (!el) { + return; + } -if (el) { // eslint-disable-next-line no-new new Vue({ el, @@ -59,4 +62,4 @@ if (el) { initDiscussionFilters(store); initSortDiscussions(store); initTimelineToggle(store); -} +}; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata.vue new file mode 100644 index 00000000000..4d6a1d5462b --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata.vue @@ -0,0 +1,106 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { + PACKAGE_TYPE_NUGET, + PACKAGE_TYPE_CONAN, + PACKAGE_TYPE_MAVEN, +} from '~/packages_and_registries/package_registry/constants'; +import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; + +export default { + i18n: { + sourceText: s__('PackageRegistry|Source project located at %{link}'), + licenseText: s__('PackageRegistry|License information located at %{link}'), + recipeText: s__('PackageRegistry|Recipe: %{recipe}'), + appGroup: s__('PackageRegistry|App group: %{group}'), + appName: s__('PackageRegistry|App name: %{name}'), + }, + components: { + DetailsRow, + GlLink, + GlSprintf, + }, + props: { + packageEntity: { + type: Object, + required: true, + }, + }, + computed: { + showMetadata() { + return ( + [PACKAGE_TYPE_NUGET, PACKAGE_TYPE_CONAN, PACKAGE_TYPE_MAVEN].includes( + this.packageEntity.packageType, + ) && this.packageEntity.metadata + ); + }, + showNugetMetadata() { + return this.packageEntity.packageType === PACKAGE_TYPE_NUGET; + }, + showConanMetadata() { + return this.packageEntity.packageType === PACKAGE_TYPE_CONAN; + }, + showMavenMetadata() { + return this.packageEntity.packageType === PACKAGE_TYPE_MAVEN; + }, + }, +}; +</script> + +<template> + <div v-if="showMetadata"> + <h3 class="gl-font-lg" data-testid="title">{{ __('Additional Metadata') }}</h3> + + <div class="gl-bg-gray-50 gl-inset-border-1-gray-100 gl-rounded-base" data-testid="main"> + <template v-if="showNugetMetadata"> + <details-row icon="project" padding="gl-p-4" dashed data-testid="nuget-source"> + <gl-sprintf :message="$options.i18n.sourceText"> + <template #link> + <gl-link :href="packageEntity.metadata.projectUrl" target="_blank">{{ + packageEntity.metadata.projectUrl + }}</gl-link> + </template> + </gl-sprintf> + </details-row> + <details-row icon="license" padding="gl-p-4" data-testid="nuget-license"> + <gl-sprintf :message="$options.i18n.licenseText"> + <template #link> + <gl-link :href="packageEntity.metadata.licenseUrl" target="_blank">{{ + packageEntity.metadata.licenseUrl + }}</gl-link> + </template> + </gl-sprintf> + </details-row> + </template> + + <details-row + v-else-if="showConanMetadata" + icon="information-o" + padding="gl-p-4" + data-testid="conan-recipe" + > + <gl-sprintf :message="$options.i18n.recipeText"> + <template #recipe>{{ packageEntity.metadata.recipe }}</template> + </gl-sprintf> + </details-row> + + <template v-else-if="showMavenMetadata"> + <details-row icon="information-o" padding="gl-p-4" dashed data-testid="maven-app"> + <gl-sprintf :message="$options.i18n.appName"> + <template #name> + <strong>{{ packageEntity.metadata.appName }}</strong> + </template> + </gl-sprintf> + </details-row> + <details-row icon="information-o" padding="gl-p-4" data-testid="maven-group"> + <gl-sprintf :message="$options.i18n.appGroup"> + <template #group> + <strong>{{ packageEntity.metadata.appGroup }}</strong> + </template> + </gl-sprintf> + </details-row> + </template> + </div> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue index e2a2fb1430d..3d3fa62fd43 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue @@ -1,8 +1,4 @@ <script> -/* - * The commented part of this component needs to be re-enabled in the refactor process, - * See here for more info: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64939 - */ import { GlBadge, GlButton, @@ -14,22 +10,39 @@ import { GlTabs, GlSprintf, } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { objectToQuery } from '~/lib/utils/url_utility'; import { s__, __ } from '~/locale'; -// import AdditionalMetadata from '~/packages/details/components/additional_metadata.vue'; -// import DependencyRow from '~/packages/details/components/dependency_row.vue'; -// import InstallationCommands from '~/packages/details/components/installation_commands.vue'; -// import PackageFiles from '~/packages/details/components/package_files.vue'; -// import PackageHistory from '~/packages/details/components/package_history.vue'; -// import PackageListRow from '~/packages/shared/components/package_list_row.vue'; -import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; +import { packageTypeToTrackCategory } from '~/packages/shared/utils'; +import AdditionalMetadata from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue'; +import DependencyRow from '~/packages_and_registries/package_registry/components/details/dependency_row.vue'; +import InstallationCommands from '~/packages_and_registries/package_registry/components/details/installation_commands.vue'; +import PackageFiles from '~/packages_and_registries/package_registry/components/details/package_files.vue'; +import PackageHistory from '~/packages_and_registries/package_registry/components/details/package_history.vue'; +import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue'; +import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue'; import { - PackageType, - TrackingActions, + PACKAGE_TYPE_NUGET, + PACKAGE_TYPE_COMPOSER, + DELETE_PACKAGE_TRACKING_ACTION, + REQUEST_DELETE_PACKAGE_TRACKING_ACTION, + CANCEL_DELETE_PACKAGE_TRACKING_ACTION, + PULL_PACKAGE_TRACKING_ACTION, + DELETE_PACKAGE_FILE_TRACKING_ACTION, + REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION, + CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION, SHOW_DELETE_SUCCESS_ALERT, -} from '~/packages/shared/constants'; -import { packageTypeToTrackCategory } from '~/packages/shared/utils'; + FETCH_PACKAGE_DETAILS_ERROR_MESSAGE, + DELETE_PACKAGE_ERROR_MESSAGE, + DELETE_PACKAGE_FILE_ERROR_MESSAGE, + DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, +} from '~/packages_and_registries/package_registry/constants'; + +import destroyPackageMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql'; +import destroyPackageFileMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql'; +import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql'; import Tracking from '~/tracking'; export default { @@ -42,16 +55,13 @@ export default { GlTab, GlTabs, GlSprintf, - PackageTitle: () => import('~/packages/details/components/package_title.vue'), - TerraformTitle: () => - import('~/packages_and_registries/infrastructure_registry/components/details_title.vue'), - PackagesListLoader, - // PackageListRow, - // DependencyRow, - // PackageHistory, - // AdditionalMetadata, - // InstallationCommands, - // PackageFiles, + PackageTitle, + VersionRow, + DependencyRow, + PackageHistory, + AdditionalMetadata, + InstallationCommands, + PackageFiles, }, directives: { GlTooltip: GlTooltipDirective, @@ -59,7 +69,7 @@ export default { }, mixins: [Tracking.mixin()], inject: [ - 'titleComponent', + 'packageId', 'projectName', 'canDelete', 'svgPath', @@ -68,72 +78,150 @@ export default { 'projectListUrl', 'groupListUrl', ], - trackingActions: { ...TrackingActions }, + trackingActions: { + DELETE_PACKAGE_TRACKING_ACTION, + REQUEST_DELETE_PACKAGE_TRACKING_ACTION, + CANCEL_DELETE_PACKAGE_TRACKING_ACTION, + PULL_PACKAGE_TRACKING_ACTION, + DELETE_PACKAGE_FILE_TRACKING_ACTION, + REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION, + CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION, + }, data() { return { fileToDelete: null, packageEntity: {}, }; }, + apollo: { + packageEntity: { + query: getPackageDetails, + variables() { + return this.queryVariables; + }, + update(data) { + return data.package; + }, + error(error) { + createFlash({ + message: FETCH_PACKAGE_DETAILS_ERROR_MESSAGE, + captureError: true, + error, + }); + }, + }, + }, computed: { + queryVariables() { + return { + id: convertToGraphQLId('Packages::Package', this.packageId), + }; + }, packageFiles() { - return this.packageEntity.packageFiles; + return this.packageEntity?.packageFiles?.nodes; }, isLoading() { - return false; + return this.$apollo.queries.packageEntity.loading; }, isValidPackage() { - return Boolean(this.packageEntity.name); + return this.isLoading || Boolean(this.packageEntity?.name); }, tracking() { return { - category: packageTypeToTrackCategory(this.packageEntity.package_type), + category: packageTypeToTrackCategory(this.packageEntity.packageType), }; }, hasVersions() { - return this.packageEntity.versions?.length > 0; + return this.packageEntity.versions?.nodes?.length > 0; }, packageDependencies() { - return this.packageEntity.dependency_links || []; + return this.packageEntity.dependencyLinks?.nodes || []; }, showDependencies() { - return this.packageEntity.package_type === PackageType.NUGET; + return this.packageEntity.packageType === PACKAGE_TYPE_NUGET; }, showFiles() { - return this.packageEntity?.package_type !== PackageType.COMPOSER; + return this.packageEntity?.packageType !== PACKAGE_TYPE_COMPOSER; }, }, methods: { formatSize(size) { return numberToHumanSize(size); }, - getPackageVersions() { - if (!this.packageEntity.versions) { - // this.fetchPackageVersions(); + async deletePackage() { + const { data } = await this.$apollo.mutate({ + mutation: destroyPackageMutation, + variables: { + id: this.packageEntity.id, + }, + }); + + if (data?.destroyPackage?.errors[0]) { + throw data.destroyPackage.errors[0]; } }, async confirmPackageDeletion() { - this.track(TrackingActions.DELETE_PACKAGE); + this.track(DELETE_PACKAGE_TRACKING_ACTION); - await this.deletePackage(); + try { + await this.deletePackage(); - const returnTo = - !this.groupListUrl || document.referrer.includes(this.projectName) - ? this.projectListUrl - : this.groupListUrl; // to avoid security issue url are supplied from backend + const returnTo = + !this.groupListUrl || document.referrer.includes(this.projectName) + ? this.projectListUrl + : this.groupListUrl; // to avoid security issue url are supplied from backend - const modalQuery = objectToQuery({ [SHOW_DELETE_SUCCESS_ALERT]: true }); + const modalQuery = objectToQuery({ [SHOW_DELETE_SUCCESS_ALERT]: true }); - window.location.replace(`${returnTo}?${modalQuery}`); + window.location.replace(`${returnTo}?${modalQuery}`); + } catch (error) { + createFlash({ + message: DELETE_PACKAGE_ERROR_MESSAGE, + type: 'warning', + captureError: true, + error, + }); + } + }, + async deletePackageFile(id) { + try { + const { data } = await this.$apollo.mutate({ + mutation: destroyPackageFileMutation, + variables: { + id, + }, + awaitRefetchQueries: true, + refetchQueries: [ + { + query: getPackageDetails, + variables: this.queryVariables, + }, + ], + }); + if (data?.destroyPackageFile?.errors[0]) { + throw data.destroyPackageFile.errors[0]; + } + createFlash({ + message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, + type: 'success', + }); + } catch (error) { + createFlash({ + message: DELETE_PACKAGE_FILE_ERROR_MESSAGE, + type: 'warning', + captureError: true, + error, + }); + } }, handleFileDelete(file) { - this.track(TrackingActions.REQUEST_DELETE_PACKAGE_FILE); + this.track(REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION); this.fileToDelete = { ...file }; this.$refs.deleteFileModal.show(); }, confirmFileDelete() { - this.track(TrackingActions.DELETE_PACKAGE_FILE); - // this.deletePackageFile(this.fileToDelete.id); + this.track(DELETE_PACKAGE_FILE_TRACKING_ACTION); + this.deletePackageFile(this.fileToDelete.id); this.fileToDelete = null; }, }, @@ -174,60 +262,48 @@ export default { :description="s__('PackageRegistry|There was a problem fetching the details for this package.')" :svg-path="svgPath" /> - - <div v-else class="packages-app"> - <component :is="titleComponent"> + <div v-else-if="!isLoading" class="packages-app"> + <package-title :package-entity="packageEntity"> <template #delete-button> <gl-button v-if="canDelete" v-gl-modal="'delete-modal'" - class="js-delete-button" variant="danger" category="primary" data-qa-selector="delete_button" + data-testid="delete-package" > {{ __('Delete') }} </gl-button> </template> - </component> + </package-title> <gl-tabs> <gl-tab :title="__('Detail')"> - <div data-qa-selector="package_information_content"> - <!-- <package-history :package-entity="packageEntity" :project-name="projectName" /> + <div v-if="!isLoading" data-qa-selector="package_information_content"> + <package-history :package-entity="packageEntity" :project-name="projectName" /> - <installation-commands - :package-entity="packageEntity" - :npm-path="npmPath" - :npm-help-path="npmHelpPath" - /> + <installation-commands :package-entity="packageEntity" /> - <additional-metadata :package-entity="packageEntity" /> --> + <additional-metadata :package-entity="packageEntity" /> </div> - <!-- <package-files + <package-files v-if="showFiles" :package-files="packageFiles" - :can-delete="canDelete" @download-file="track($options.trackingActions.PULL_PACKAGE)" @delete-file="handleFileDelete" - /> --> + /> </gl-tab> - <gl-tab v-if="showDependencies" title-item-class="js-dependencies-tab"> + <gl-tab v-if="showDependencies"> <template #title> <span>{{ __('Dependencies') }}</span> - <gl-badge size="sm" data-testid="dependencies-badge">{{ - packageDependencies.length - }}</gl-badge> + <gl-badge size="sm">{{ packageDependencies.length }}</gl-badge> </template> <template v-if="packageDependencies.length > 0"> - <dependency-row - v-for="(dep, index) in packageDependencies" - :key="index" - :dependency="dep" - /> + <dependency-row v-for="dep in packageDependencies" :key="dep.id" :dependency-link="dep" /> </template> <p v-else class="gl-mt-3" data-testid="no-dependencies-message"> @@ -235,24 +311,9 @@ export default { </p> </gl-tab> - <gl-tab - :title="__('Other versions')" - title-item-class="js-versions-tab" - @click="getPackageVersions" - > - <template v-if="isLoading && !hasVersions"> - <packages-list-loader /> - </template> - - <template v-else-if="hasVersions"> - <!-- <package-list-row - v-for="v in packageEntity.versions" - :key="v.id" - :package-entity="{ name: packageEntity.name, ...v }" - :package-link="v.id.toString()" - :disable-delete="true" - :show-package-type="false" - /> --> + <gl-tab :title="__('Other versions')" title-item-class="js-versions-tab"> + <template v-if="hasVersions"> + <version-row v-for="v in packageEntity.versions.nodes" :key="v.id" :package-entity="v" /> </template> <p v-else class="gl-mt-3" data-testid="no-versions-message"> @@ -263,8 +324,8 @@ export default { <gl-modal ref="deleteModal" - class="js-delete-modal" modal-id="delete-modal" + data-testid="delete-modal" :action-primary="$options.modal.packageDeletePrimaryAction" :action-cancel="$options.modal.cancelAction" @primary="confirmPackageDeletion" @@ -287,6 +348,7 @@ export default { modal-id="delete-file-modal" :action-primary="$options.modal.fileDeletePrimaryAction" :action-cancel="$options.modal.cancelAction" + data-testid="delete-file-modal" @primary="confirmFileDelete" @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)" > diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/composer_installation.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/composer_installation.vue new file mode 100644 index 00000000000..cc629ae394c --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/composer_installation.vue @@ -0,0 +1,87 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue'; +import { + TRACKING_ACTION_COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND, + TRACKING_ACTION_COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND, + TRACKING_LABEL_CODE_INSTRUCTION, +} from '~/packages_and_registries/package_registry/constants'; +import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; + +export default { + name: 'ComposerInstallation', + components: { + InstallationTitle, + CodeInstruction, + GlLink, + GlSprintf, + }, + inject: ['composerHelpPath', 'composerConfigRepositoryName', 'composerPath', 'groupListUrl'], + props: { + packageEntity: { + type: Object, + required: true, + }, + }, + computed: { + composerRegistryInclude() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `composer config repositories.${this.composerConfigRepositoryName} '{"type": "composer", "url": "${this.composerPath}"}'`; + }, + composerPackageInclude() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `composer req ${[this.packageEntity.name]}:${this.packageEntity.version}`; + }, + groupExists() { + return this.groupListUrl?.length > 0; + }, + }, + i18n: { + registryInclude: s__('PackageRegistry|Add composer registry'), + copyRegistryInclude: s__('PackageRegistry|Copy registry include'), + packageInclude: s__('PackageRegistry|Install package version'), + copyPackageInclude: s__('PackageRegistry|Copy require package include'), + infoLine: s__( + 'PackageRegistry|For more information on Composer packages in GitLab, %{linkStart}see the documentation.%{linkEnd}', + ), + }, + tracking: { + TRACKING_ACTION_COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND, + TRACKING_ACTION_COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND, + TRACKING_LABEL_CODE_INSTRUCTION, + }, + installOptions: [{ value: 'composer', label: s__('PackageRegistry|Show Composer commands') }], +}; +</script> + +<template> + <div v-if="groupExists" data-testid="root-node"> + <installation-title package-type="composer" :options="$options.installOptions" /> + + <code-instruction + :label="$options.i18n.registryInclude" + :instruction="composerRegistryInclude" + :copy-text="$options.i18n.copyRegistryInclude" + :tracking-action="$options.tracking.TRACKING_ACTION_COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND" + :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION" + data-testid="registry-include" + /> + + <code-instruction + :label="$options.i18n.packageInclude" + :instruction="composerPackageInclude" + :copy-text="$options.i18n.copyPackageInclude" + :tracking-action="$options.tracking.TRACKING_ACTION_COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND" + :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION" + data-testid="package-include" + /> + <span data-testid="help-text"> + <gl-sprintf :message="$options.i18n.infoLine"> + <template #link="{ content }"> + <gl-link :href="composerHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/conan_installation.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/conan_installation.vue new file mode 100644 index 00000000000..99e27c9d44a --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/conan_installation.vue @@ -0,0 +1,79 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue'; +import { + TRACKING_ACTION_COPY_CONAN_COMMAND, + TRACKING_ACTION_COPY_CONAN_SETUP_COMMAND, + TRACKING_LABEL_CODE_INSTRUCTION, +} from '~/packages_and_registries/package_registry/constants'; +import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; + +export default { + name: 'ConanInstallation', + components: { + InstallationTitle, + CodeInstruction, + GlLink, + GlSprintf, + }, + inject: ['conanHelpPath', 'conanPath'], + props: { + packageEntity: { + type: Object, + required: true, + }, + }, + computed: { + conanInstallationCommand() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `conan install ${this.packageEntity.name} --remote=gitlab`; + }, + conanSetupCommand() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `conan remote add gitlab ${this.conanPath}`; + }, + }, + i18n: { + helpText: s__( + 'PackageRegistry|For more information on the Conan registry, %{linkStart}see the documentation%{linkEnd}.', + ), + }, + tracking: { + TRACKING_ACTION_COPY_CONAN_COMMAND, + TRACKING_ACTION_COPY_CONAN_SETUP_COMMAND, + TRACKING_LABEL_CODE_INSTRUCTION, + }, + + installOptions: [{ value: 'conan', label: s__('PackageRegistry|Show Conan commands') }], +}; +</script> + +<template> + <div> + <installation-title package-type="conan" :options="$options.installOptions" /> + + <code-instruction + :label="s__('PackageRegistry|Conan Command')" + :instruction="conanInstallationCommand" + :copy-text="s__('PackageRegistry|Copy Conan Command')" + :tracking-action="$options.tracking.TRACKING_ACTION_COPY_CONAN_COMMAND" + :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION" + /> + + <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> + + <code-instruction + :label="s__('PackageRegistry|Add Conan Remote')" + :instruction="conanSetupCommand" + :copy-text="s__('PackageRegistry|Copy Conan Setup Command')" + :tracking-action="$options.tracking.TRACKING_ACTION_COPY_CONAN_SETUP_COMMAND" + :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION" + /> + <gl-sprintf :message="$options.i18n.helpText"> + <template #link="{ content }"> + <gl-link :href="conanHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/dependency_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/dependency_row.vue new file mode 100644 index 00000000000..95236eea0b5 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/dependency_row.vue @@ -0,0 +1,38 @@ +<script> +export default { + name: 'DependencyRow', + props: { + dependencyLink: { + type: Object, + required: true, + }, + }, + computed: { + showVersion() { + return Boolean(this.dependencyLink.dependency?.versionPattern); + }, + showTargetFramework() { + return Boolean(this.dependencyLink.metadata?.targetFramework); + }, + }, +}; +</script> + +<template> + <div class="gl-responsive-table-row"> + <div class="table-section section-50"> + <strong class="gl-text-body">{{ dependencyLink.dependency.name }}</strong> + <span v-if="showTargetFramework" data-testid="target-framework"> + ({{ dependencyLink.metadata.targetFramework }}) + </span> + </div> + + <div + v-if="showVersion" + class="table-section section-50 gl-display-flex gl-md-justify-content-end" + data-testid="version-pattern" + > + <span class="gl-text-body">{{ dependencyLink.dependency.versionPattern }}</span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/file_sha.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/file_sha.vue new file mode 100644 index 00000000000..a25839be7e1 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/file_sha.vue @@ -0,0 +1,41 @@ +<script> +import { s__ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; + +export default { + name: 'FileSha', + components: { + DetailsRow, + ClipboardButton, + }, + props: { + sha: { + type: String, + required: true, + }, + title: { + type: String, + required: true, + }, + }, + i18n: { + copyButtonTitle: s__('PackageRegistry|Copy SHA'), + }, +}; +</script> + +<template> + <details-row dashed> + <div class="gl-px-4"> + {{ title }}: + {{ sha }} + <clipboard-button + :text="sha" + :title="$options.i18n.copyButtonTitle" + category="tertiary" + size="small" + /> + </div> + </details-row> +</template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_commands.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_commands.vue new file mode 100644 index 00000000000..122d444e859 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_commands.vue @@ -0,0 +1,45 @@ +<script> +import { + PACKAGE_TYPE_CONAN, + PACKAGE_TYPE_MAVEN, + PACKAGE_TYPE_NPM, + PACKAGE_TYPE_NUGET, + PACKAGE_TYPE_PYPI, + PACKAGE_TYPE_COMPOSER, +} from '~/packages_and_registries/package_registry/constants'; +import ComposerInstallation from './composer_installation.vue'; +import ConanInstallation from './conan_installation.vue'; +import MavenInstallation from './maven_installation.vue'; +import NpmInstallation from './npm_installation.vue'; +import NugetInstallation from './nuget_installation.vue'; +import PypiInstallation from './pypi_installation.vue'; + +export default { + name: 'InstallationCommands', + components: { + [PACKAGE_TYPE_CONAN]: ConanInstallation, + [PACKAGE_TYPE_MAVEN]: MavenInstallation, + [PACKAGE_TYPE_NPM]: NpmInstallation, + [PACKAGE_TYPE_NUGET]: NugetInstallation, + [PACKAGE_TYPE_PYPI]: PypiInstallation, + [PACKAGE_TYPE_COMPOSER]: ComposerInstallation, + }, + props: { + packageEntity: { + type: Object, + required: true, + }, + }, + computed: { + installationComponent() { + return this.$options.components[this.packageEntity.packageType]; + }, + }, +}; +</script> + +<template> + <div v-if="installationComponent"> + <component :is="installationComponent" :package-entity="packageEntity" /> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_title.vue new file mode 100644 index 00000000000..43133bf7825 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_title.vue @@ -0,0 +1,38 @@ +<script> +import PersistedDropdownSelection from '~/vue_shared/components/registry/persisted_dropdown_selection.vue'; + +export default { + name: 'InstallationTitle', + components: { + PersistedDropdownSelection, + }, + props: { + packageType: { + type: String, + required: true, + }, + options: { + type: Array, + required: true, + }, + }, + computed: { + storageKey() { + return `package_${this.packageType}_installation_instructions`; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> + <h3 class="gl-font-lg">{{ __('Installation') }}</h3> + <div> + <persisted-dropdown-selection + :storage-key="storageKey" + :options="options" + @change="$emit('change', $event)" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/maven_installation.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/maven_installation.vue new file mode 100644 index 00000000000..2070f0bbca0 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/maven_installation.vue @@ -0,0 +1,229 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue'; +import { + TRACKING_ACTION_COPY_MAVEN_XML, + TRACKING_ACTION_COPY_MAVEN_COMMAND, + TRACKING_ACTION_COPY_MAVEN_SETUP, + TRACKING_ACTION_COPY_GRADLE_INSTALL_COMMAND, + TRACKING_ACTION_COPY_GRADLE_ADD_TO_SOURCE_COMMAND, + TRACKING_ACTION_COPY_KOTLIN_INSTALL_COMMAND, + TRACKING_ACTION_COPY_KOTLIN_ADD_TO_SOURCE_COMMAND, + TRACKING_LABEL_CODE_INSTRUCTION, + TRACKING_LABEL_MAVEN_INSTALLATION, +} from '~/packages_and_registries/package_registry/constants'; +import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; + +export default { + name: 'MavenInstallation', + components: { + InstallationTitle, + CodeInstruction, + GlLink, + GlSprintf, + }, + inject: ['mavenHelpPath', 'mavenPath'], + props: { + packageEntity: { + type: Object, + required: true, + }, + }, + data() { + return { + instructionType: 'maven', + }; + }, + computed: { + appGroup() { + return this.packageEntity.metadata.appGroup; + }, + appName() { + return this.packageEntity.metadata.appName; + }, + appVersion() { + return this.packageEntity.metadata.appVersion; + }, + mavenInstallationXml() { + return `<dependency> + <groupId>${this.appGroup}</groupId> + <artifactId>${this.appName}</artifactId> + <version>${this.appVersion}</version> +</dependency>`; + }, + + mavenInstallationCommand() { + return `mvn dependency:get -Dartifact=${this.appGroup}:${this.appName}:${this.appVersion}`; + }, + + mavenSetupXml() { + return `<repositories> + <repository> + <id>gitlab-maven</id> + <url>${this.mavenPath}</url> + </repository> +</repositories> + +<distributionManagement> + <repository> + <id>gitlab-maven</id> + <url>${this.mavenPath}</url> + </repository> + + <snapshotRepository> + <id>gitlab-maven</id> + <url>${this.mavenPath}</url> + </snapshotRepository> +</distributionManagement>`; + }, + + gradleGroovyInstalCommand() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `implementation '${this.appGroup}:${this.appName}:${this.appVersion}'`; + }, + + gradleGroovyAddSourceCommand() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `maven { + url '${this.mavenPath}' +}`; + }, + + gradleKotlinInstalCommand() { + return `implementation("${this.appGroup}:${this.appName}:${this.appVersion}")`; + }, + + gradleKotlinAddSourceCommand() { + return `maven("${this.mavenPath}")`; + }, + showMaven() { + return this.instructionType === 'maven'; + }, + showGroovy() { + return this.instructionType === 'groovy'; + }, + }, + i18n: { + xmlText: s__( + `PackageRegistry|Copy and paste this inside your %{codeStart}pom.xml%{codeEnd} %{codeStart}dependencies%{codeEnd} block.`, + ), + setupText: s__( + `PackageRegistry|If you haven't already done so, you will need to add the below to your %{codeStart}pom.xml%{codeEnd} file.`, + ), + helpText: s__( + 'PackageRegistry|For more information on the Maven registry, %{linkStart}see the documentation%{linkEnd}.', + ), + }, + tracking: { + TRACKING_ACTION_COPY_MAVEN_XML, + TRACKING_ACTION_COPY_MAVEN_COMMAND, + TRACKING_ACTION_COPY_MAVEN_SETUP, + TRACKING_ACTION_COPY_GRADLE_INSTALL_COMMAND, + TRACKING_ACTION_COPY_GRADLE_ADD_TO_SOURCE_COMMAND, + TRACKING_ACTION_COPY_KOTLIN_INSTALL_COMMAND, + TRACKING_ACTION_COPY_KOTLIN_ADD_TO_SOURCE_COMMAND, + TRACKING_LABEL_CODE_INSTRUCTION, + TRACKING_LABEL_MAVEN_INSTALLATION, + }, + + installOptions: [ + { value: 'maven', label: s__('PackageRegistry|Maven XML') }, + { value: 'groovy', label: s__('PackageRegistry|Gradle Groovy DSL') }, + { value: 'kotlin', label: s__('PackageRegistry|Gradle Kotlin DSL') }, + ], +}; +</script> + +<template> + <div> + <installation-title + package-type="maven" + :options="$options.installOptions" + @change="instructionType = $event" + /> + + <template v-if="showMaven"> + <p> + <gl-sprintf :message="$options.i18n.xmlText"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + + <code-instruction + :instruction="mavenInstallationXml" + :copy-text="s__('PackageRegistry|Copy Maven XML')" + :tracking-action="$options.tracking.TRACKING_ACTION_COPY_MAVEN_XML" + :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION" + multiline + /> + + <code-instruction + :label="s__('PackageRegistry|Maven Command')" + :instruction="mavenInstallationCommand" + :copy-text="s__('PackageRegistry|Copy Maven command')" + :tracking-action="$options.tracking.TRACKING_ACTION_COPY_MAVEN_COMMAND" + :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION" + /> + + <h3 class="gl-font-lg">{{ s__('PackageRegistry|Registry setup') }}</h3> + <p> + <gl-sprintf :message="$options.i18n.setupText"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + <code-instruction + :instruction="mavenSetupXml" + :copy-text="s__('PackageRegistry|Copy Maven registry XML')" + :tracking-action="$options.tracking.TRACKING_ACTION_COPY_MAVEN_SETUP" + :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION" + multiline + /> + <gl-sprintf :message="$options.i18n.helpText"> + <template #link="{ content }"> + <gl-link :href="mavenHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </template> + <template v-else-if="showGroovy"> + <code-instruction + class="gl-mb-5" + :label="s__('PackageRegistry|Gradle Groovy DSL install command')" + :instruction="gradleGroovyInstalCommand" + :copy-text="s__('PackageRegistry|Copy Gradle Groovy DSL install command')" + :tracking-action="$options.tracking.TRACKING_ACTION_COPY_GRADLE_INSTALL_COMMAND" + :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION" + /> + <code-instruction + :label="s__('PackageRegistry|Add Gradle Groovy DSL repository command')" + :instruction="gradleGroovyAddSourceCommand" + :copy-text="s__('PackageRegistry|Copy add Gradle Groovy DSL repository command')" + :tracking-action="$options.tracking.TRACKING_ACTION_COPY_GRADLE_ADD_TO_SOURCE_COMMAND" + :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION" + multiline + /> + </template> + <template v-else> + <code-instruction + class="gl-mb-5" + :label="s__('PackageRegistry|Gradle Kotlin DSL install command')" + :instruction="gradleKotlinInstalCommand" + :copy-text="s__('PackageRegistry|Copy Gradle Kotlin DSL install command')" + :tracking-action="$options.tracking.TRACKING_ACTION_COPY_KOTLIN_INSTALL_COMMAND" + :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION" + /> + <code-instruction + :label="s__('PackageRegistry|Add Gradle Kotlin DSL repository command')" + :instruction="gradleKotlinAddSourceCommand" + :copy-text="s__('PackageRegistry|Copy add Gradle Kotlin DSL repository command')" + :tracking-action="$options.tracking.TRACKING_ACTION_COPY_KOTLIN_ADD_TO_SOURCE_COMMAND" + :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION" + multiline + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/npm_installation.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/npm_installation.vue new file mode 100644 index 00000000000..47081e23318 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/npm_installation.vue @@ -0,0 +1,141 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue'; +import { + TRACKING_ACTION_COPY_NPM_INSTALL_COMMAND, + TRACKING_ACTION_COPY_NPM_SETUP_COMMAND, + TRACKING_ACTION_COPY_YARN_INSTALL_COMMAND, + TRACKING_ACTION_COPY_YARN_SETUP_COMMAND, + TRACKING_LABEL_CODE_INSTRUCTION, + NPM_PACKAGE_MANAGER, + YARN_PACKAGE_MANAGER, +} from '~/packages_and_registries/package_registry/constants'; +import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; + +export default { + name: 'NpmInstallation', + components: { + InstallationTitle, + CodeInstruction, + GlLink, + GlSprintf, + }, + inject: ['npmHelpPath', 'npmPath'], + props: { + packageEntity: { + type: Object, + required: true, + }, + }, + data() { + return { + instructionType: NPM_PACKAGE_MANAGER, + }; + }, + computed: { + npmCommand() { + return this.npmInstallationCommand(NPM_PACKAGE_MANAGER); + }, + npmSetup() { + return this.npmSetupCommand(NPM_PACKAGE_MANAGER); + }, + yarnCommand() { + return this.npmInstallationCommand(YARN_PACKAGE_MANAGER); + }, + yarnSetupCommand() { + return this.npmSetupCommand(YARN_PACKAGE_MANAGER); + }, + showNpm() { + return this.instructionType === NPM_PACKAGE_MANAGER; + }, + }, + methods: { + npmInstallationCommand(type) { + // eslint-disable-next-line @gitlab/require-i18n-strings + const instruction = type === NPM_PACKAGE_MANAGER ? 'npm i' : 'yarn add'; + + return `${instruction} ${this.packageEntity.name}`; + }, + npmSetupCommand(type) { + const scope = this.packageEntity.name.substring(0, this.packageEntity.name.indexOf('/')); + + if (type === NPM_PACKAGE_MANAGER) { + return `echo ${scope}:registry=${this.npmPath}/ >> .npmrc`; + } + + return `echo \\"${scope}:registry\\" \\"${this.npmPath}/\\" >> .yarnrc`; + }, + }, + packageManagers: { + NPM_PACKAGE_MANAGER, + }, + tracking: { + TRACKING_ACTION_COPY_NPM_INSTALL_COMMAND, + TRACKING_ACTION_COPY_NPM_SETUP_COMMAND, + TRACKING_ACTION_COPY_YARN_INSTALL_COMMAND, + TRACKING_ACTION_COPY_YARN_SETUP_COMMAND, + TRACKING_LABEL_CODE_INSTRUCTION, + }, + i18n: { + helpText: s__( + 'PackageRegistry|You may also need to setup authentication using an auth token. %{linkStart}See the documentation%{linkEnd} to find out more.', + ), + }, + installOptions: [ + { value: NPM_PACKAGE_MANAGER, label: s__('PackageRegistry|Show NPM commands') }, + { value: YARN_PACKAGE_MANAGER, label: s__('PackageRegistry|Show Yarn commands') }, + ], +}; +</script> + +<template> + <div> + <installation-title + :package-type="$options.packageManagers.NPM_PACKAGE_MANAGER" + :options="$options.installOptions" + @change="instructionType = $event" + /> + + <code-instruction + v-if="showNpm" + :instruction="npmCommand" + :copy-text="s__('PackageRegistry|Copy npm command')" + :tracking-action="$options.tracking.TRACKING_ACTION_COPY_NPM_INSTALL_COMMAND" + :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION" + /> + + <code-instruction + v-else + :instruction="yarnCommand" + :copy-text="s__('PackageRegistry|Copy yarn command')" + :tracking-action="$options.tracking.TRACKING_ACTION_COPY_YARN_INSTALL_COMMAND" + :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION" + /> + + <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> + + <code-instruction + v-if="showNpm" + :instruction="npmSetup" + :copy-text="s__('PackageRegistry|Copy npm setup command')" + :tracking-action="$options.tracking.TRACKING_ACTION_COPY_NPM_SETUP_COMMAND" + :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION" + /> + + <code-instruction + v-else + :instruction="yarnSetupCommand" + :copy-text="s__('PackageRegistry|Copy yarn setup command')" + :tracking-action="$options.tracking.TRACKING_ACTION_COPY_YARN_SETUP_COMMAND" + :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION" + /> + + <gl-sprintf :message="$options.i18n.helpText"> + <template #link="{ content }"> + <gl-link :href="npmHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/nuget_installation.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/nuget_installation.vue new file mode 100644 index 00000000000..2e9991b7be5 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/nuget_installation.vue @@ -0,0 +1,75 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue'; +import { + TRACKING_ACTION_COPY_NUGET_INSTALL_COMMAND, + TRACKING_ACTION_COPY_NUGET_SETUP_COMMAND, + TRACKING_LABEL_CODE_INSTRUCTION, +} from '~/packages_and_registries/package_registry/constants'; +import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; + +export default { + name: 'NugetInstallation', + components: { + InstallationTitle, + CodeInstruction, + GlLink, + GlSprintf, + }, + inject: ['nugetHelpPath', 'nugetPath'], + props: { + packageEntity: { + type: Object, + required: true, + }, + }, + computed: { + nugetInstallationCommand() { + return `nuget install ${this.packageEntity.name} -Source "GitLab"`; + }, + nugetSetupCommand() { + return `nuget source Add -Name "GitLab" -Source "${this.nugetPath}" -UserName <your_username> -Password <your_token>`; + }, + }, + tracking: { + TRACKING_ACTION_COPY_NUGET_INSTALL_COMMAND, + TRACKING_ACTION_COPY_NUGET_SETUP_COMMAND, + TRACKING_LABEL_CODE_INSTRUCTION, + }, + i18n: { + helpText: s__( + 'PackageRegistry|For more information on the NuGet registry, %{linkStart}see the documentation%{linkEnd}.', + ), + }, + installOptions: [{ value: 'nuget', label: s__('PackageRegistry|Show Nuget commands') }], +}; +</script> + +<template> + <div> + <installation-title package-type="nuget" :options="$options.installOptions" /> + + <code-instruction + :label="s__('PackageRegistry|NuGet Command')" + :instruction="nugetInstallationCommand" + :copy-text="s__('PackageRegistry|Copy NuGet Command')" + :tracking-action="$options.tracking.TRACKING_ACTION_COPY_NUGET_INSTALL_COMMAND" + :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION" + /> + <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> + + <code-instruction + :label="s__('PackageRegistry|Add NuGet Source')" + :instruction="nugetSetupCommand" + :copy-text="s__('PackageRegistry|Copy NuGet Setup Command')" + :tracking-action="$options.tracking.TRACKING_ACTION_COPY_NUGET_SETUP_COMMAND" + :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION" + /> + <gl-sprintf :message="$options.i18n.helpText"> + <template #link="{ content }"> + <gl-link :href="nugetHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue new file mode 100644 index 00000000000..bf7fe6fb91b --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue @@ -0,0 +1,163 @@ +<script> +import { GlLink, GlTable, GlDropdownItem, GlDropdown, GlIcon, GlButton } from '@gitlab/ui'; +import { last } from 'lodash'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { __ } from '~/locale'; +import FileSha from '~/packages_and_registries/package_registry/components/details/file_sha.vue'; +import Tracking from '~/tracking'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + name: 'PackageFiles', + components: { + GlLink, + GlTable, + GlIcon, + GlDropdown, + GlDropdownItem, + GlButton, + FileIcon, + TimeAgoTooltip, + FileSha, + }, + mixins: [Tracking.mixin()], + inject: ['canDelete'], + props: { + packageFiles: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + filesTableRows() { + return this.packageFiles.map((pf) => ({ + ...pf, + size: this.formatSize(pf.size), + pipeline: last(pf.pipelines), + })); + }, + showCommitColumn() { + // note that this is always false for now since we do not return + // pipelines associated to files for performance concerns + return this.filesTableRows.some((row) => Boolean(row.pipeline?.id)); + }, + filesTableHeaderFields() { + return [ + { + key: 'name', + label: __('Name'), + }, + { + key: 'commit', + label: __('Commit'), + hide: !this.showCommitColumn, + }, + { + key: 'size', + label: __('Size'), + }, + { + key: 'created', + label: __('Created'), + class: 'gl-text-right', + }, + { + key: 'actions', + label: '', + hide: !this.canDelete, + class: 'gl-text-right', + tdClass: 'gl-w-4', + }, + ].filter((c) => !c.hide); + }, + }, + methods: { + formatSize(size) { + return numberToHumanSize(size); + }, + hasDetails(item) { + return item.fileSha256 || item.fileMd5 || item.fileSha1; + }, + }, + i18n: { + deleteFile: __('Delete file'), + }, +}; +</script> + +<template> + <div> + <h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3> + <gl-table + :fields="filesTableHeaderFields" + :items="filesTableRows" + :tbody-tr-attr="{ 'data-testid': 'file-row' }" + > + <template #cell(name)="{ item, toggleDetails, detailsShowing }"> + <gl-button + v-if="hasDetails(item)" + :icon="detailsShowing ? 'angle-up' : 'angle-down'" + :aria-label="detailsShowing ? __('Collapse') : __('Expand')" + category="tertiary" + size="small" + @click="toggleDetails" + /> + <gl-link + :href="item.downloadPath" + class="gl-text-gray-500" + data-testid="download-link" + @click="$emit('download-file')" + > + <file-icon + :file-name="item.fileName" + css-classes="gl-relative file-icon" + class="gl-mr-1 gl-relative" + /> + <span>{{ item.fileName }}</span> + </gl-link> + </template> + + <template #cell(commit)="{ item }"> + <gl-link + v-if="item.pipeline && item.pipeline" + :href="item.pipeline.commitPath" + class="gl-text-gray-500" + data-testid="commit-link" + >{{ item.pipeline.sha }}</gl-link + > + </template> + + <template #cell(created)="{ item }"> + <time-ago-tooltip :time="item.createdAt" /> + </template> + + <template #cell(actions)="{ item }"> + <gl-dropdown category="tertiary" right> + <template #button-content> + <gl-icon name="ellipsis_v" /> + </template> + <gl-dropdown-item data-testid="delete-file" @click="$emit('delete-file', item)"> + {{ $options.i18n.deleteFile }} + </gl-dropdown-item> + </gl-dropdown> + </template> + + <template #row-details="{ item }"> + <div + class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-bg-gray-10 gl-rounded-base gl-inset-border-1-gray-100" + > + <file-sha + v-if="item.fileSha256" + data-testid="sha-256" + title="SHA-256" + :sha="item.fileSha256" + /> + <file-sha v-if="item.fileMd5" data-testid="md5" title="MD5" :sha="item.fileMd5" /> + <file-sha v-if="item.fileSha1" data-testid="sha-1" title="SHA-1" :sha="item.fileSha1" /> + </div> + </template> + </gl-table> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue new file mode 100644 index 00000000000..af4a984add4 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue @@ -0,0 +1,169 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { first } from 'lodash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { truncateSha } from '~/lib/utils/text_utility'; +import { s__, n__ } from '~/locale'; +import { HISTORY_PIPELINES_LIMIT } from '~/packages/details/constants'; +import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + name: 'PackageHistory', + i18n: { + createdOn: s__('PackageRegistry|%{name} version %{version} was first created %{datetime}'), + createdByCommitText: s__('PackageRegistry|Created by commit %{link} on branch %{branch}'), + createdByPipelineText: s__( + 'PackageRegistry|Built by pipeline %{link} triggered %{datetime} by %{author}', + ), + publishText: s__('PackageRegistry|Published to the %{project} Package Registry %{datetime}'), + combinedUpdateText: s__( + 'PackageRegistry|Package updated by commit %{link} on branch %{branch}, built by pipeline %{pipeline}, and published to the registry %{datetime}', + ), + archivedPipelineMessageSingular: s__('PackageRegistry|Package has %{number} archived update'), + archivedPipelineMessagePlural: s__('PackageRegistry|Package has %{number} archived updates'), + }, + components: { + GlLink, + GlSprintf, + HistoryItem, + TimeAgoTooltip, + }, + props: { + packageEntity: { + type: Object, + required: true, + }, + projectName: { + type: String, + required: true, + }, + }, + data() { + return { + showDescription: false, + }; + }, + computed: { + pipelines() { + return this.packageEntity?.pipelines?.nodes || []; + }, + firstPipeline() { + return first(this.pipelines); + }, + lastPipelines() { + return this.pipelines.slice(1).slice(-HISTORY_PIPELINES_LIMIT); + }, + showPipelinesInfo() { + return Boolean(this.firstPipeline?.id); + }, + archiviedLines() { + return Math.max(this.pipelines.length - HISTORY_PIPELINES_LIMIT - 1, 0); + }, + archivedPipelineMessage() { + return n__( + this.$options.i18n.archivedPipelineMessageSingular, + this.$options.i18n.archivedPipelineMessagePlural, + this.archiviedLines, + ); + }, + }, + methods: { + truncate(value) { + return truncateSha(value); + }, + convertToBaseId(value) { + return getIdFromGraphQLId(value); + }, + }, +}; +</script> + +<template> + <div class="issuable-discussion"> + <h3 class="gl-font-lg" data-testid="title">{{ __('History') }}</h3> + <ul class="timeline main-notes-list notes gl-mb-4" data-testid="timeline"> + <history-item icon="clock" data-testid="created-on"> + <gl-sprintf :message="$options.i18n.createdOn"> + <template #name> + <strong>{{ packageEntity.name }}</strong> + </template> + <template #version> + <strong>{{ packageEntity.version }}</strong> + </template> + <template #datetime> + <time-ago-tooltip :time="packageEntity.createdAt" /> + </template> + </gl-sprintf> + </history-item> + + <template v-if="showPipelinesInfo"> + <!-- FIRST PIPELINE BLOCK --> + <history-item icon="commit" data-testid="first-pipeline-commit"> + <gl-sprintf :message="$options.i18n.createdByCommitText"> + <template #link> + <gl-link :href="firstPipeline.commitPath">#{{ truncate(firstPipeline.sha) }}</gl-link> + </template> + <template #branch> + <strong>{{ firstPipeline.ref }}</strong> + </template> + </gl-sprintf> + </history-item> + <history-item icon="pipeline" data-testid="first-pipeline-pipeline"> + <gl-sprintf :message="$options.i18n.createdByPipelineText"> + <template #link> + <gl-link :href="firstPipeline.path">#{{ convertToBaseId(firstPipeline.id) }}</gl-link> + </template> + <template #datetime> + <time-ago-tooltip :time="firstPipeline.createdAt" /> + </template> + <template #author>{{ firstPipeline.user.name }}</template> + </gl-sprintf> + </history-item> + </template> + + <!-- PUBLISHED LINE --> + <history-item icon="package" data-testid="published"> + <gl-sprintf :message="$options.i18n.publishText"> + <template #project> + <strong>{{ projectName }}</strong> + </template> + <template #datetime> + <time-ago-tooltip :time="packageEntity.createdAt" /> + </template> + </gl-sprintf> + </history-item> + + <history-item v-if="archiviedLines" icon="history" data-testid="archived"> + <gl-sprintf :message="archivedPipelineMessage"> + <template #number> + <strong>{{ archiviedLines }}</strong> + </template> + </gl-sprintf> + </history-item> + + <!-- PIPELINES LIST ENTRIES --> + <history-item + v-for="pipeline in lastPipelines" + :key="pipeline.id" + icon="pencil" + data-testid="pipeline-entry" + > + <gl-sprintf :message="$options.i18n.combinedUpdateText"> + <template #link> + <gl-link :href="pipeline.commitPath">#{{ truncate(pipeline.sha) }}</gl-link> + </template> + <template #branch> + <strong>{{ pipeline.ref }}</strong> + </template> + <template #pipeline> + <gl-link :href="pipeline.path">#{{ convertToBaseId(pipeline.id) }}</gl-link> + </template> + <template #datetime> + <time-ago-tooltip :time="pipeline.createdAt" /> + </template> + </gl-sprintf> + </history-item> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue new file mode 100644 index 00000000000..65547af3913 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue @@ -0,0 +1,134 @@ +<script> +import { GlIcon, GlSprintf, GlBadge } from '@gitlab/ui'; +import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { __ } from '~/locale'; +import PackageTags from '~/packages/shared/components/package_tags.vue'; +import { PACKAGE_TYPE_NUGET } from '~/packages_and_registries/package_registry/constants'; +import { getPackageTypeLabel } from '~/packages_and_registries/package_registry/utils'; +import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + name: 'PackageTitle', + components: { + TitleArea, + GlIcon, + GlSprintf, + PackageTags, + MetadataItem, + GlBadge, + TimeAgoTooltip, + }, + i18n: { + packageInfo: __('v%{version} published %{timeAgo}'), + }, + props: { + packageEntity: { + type: Object, + required: true, + }, + }, + data() { + return { + isDesktop: true, + }; + }, + computed: { + packageTypeDisplay() { + return getPackageTypeLabel(this.packageEntity.packageType); + }, + packagePipeline() { + return this.packageEntity.pipelines?.nodes[0]; + }, + packageIcon() { + if (this.packageEntity.packageType === PACKAGE_TYPE_NUGET) { + return this.packageEntity.metadata?.iconUrl || null; + } + return null; + }, + hasTagsToDisplay() { + return Boolean(this.packageEntity.tags?.nodes && this.packageEntity.tags?.nodes.length); + }, + totalSize() { + return this.packageEntity.packageFiles + ? numberToHumanSize( + this.packageEntity.packageFiles.nodes.reduce((acc, p) => acc + Number(p.size), 0), + ) + : '0'; + }, + }, + mounted() { + this.isDesktop = GlBreakpointInstance.isDesktop(); + }, + methods: { + dynamicSlotName(index) { + return `metadata-tag${index}`; + }, + }, +}; +</script> + +<template> + <title-area :title="packageEntity.name" :avatar="packageIcon" data-qa-selector="package_title"> + <template #sub-header> + <gl-icon name="eye" class="gl-mr-3" /> + <span data-testid="sub-header"> + <gl-sprintf :message="$options.i18n.packageInfo"> + <template #version> + {{ packageEntity.version }} + </template> + + <template #timeAgo> + <time-ago-tooltip + v-if="packageEntity.createdAt" + class="gl-ml-2" + :time="packageEntity.createdAt" + /> + </template> + </gl-sprintf> + </span> + </template> + + <template v-if="packageTypeDisplay" #metadata-type> + <metadata-item data-testid="package-type" icon="package" :text="packageTypeDisplay" /> + </template> + + <template #metadata-size> + <metadata-item data-testid="package-size" icon="disk" :text="totalSize" /> + </template> + + <template v-if="packagePipeline" #metadata-pipeline> + <metadata-item + data-testid="pipeline-project" + icon="review-list" + :text="packagePipeline.project.name" + :link="packagePipeline.project.webUrl" + /> + </template> + + <template v-if="packagePipeline && packagePipeline.ref" #metadata-ref> + <metadata-item data-testid="package-ref" icon="branch" :text="packagePipeline.ref" /> + </template> + + <template v-if="isDesktop && hasTagsToDisplay" #metadata-tags> + <package-tags :tag-display-limit="2" :tags="packageEntity.tags.nodes" hide-label /> + </template> + + <!-- we need to duplicate the package tags on mobile to ensure proper styling inside the flex wrap --> + <template + v-for="(tag, index) in packageEntity.tags.nodes" + v-else-if="hasTagsToDisplay" + #[dynamicSlotName(index)] + > + <gl-badge :key="index" class="gl-my-1" data-testid="tag-badge" variant="info" size="sm"> + {{ tag.name }} + </gl-badge> + </template> + + <template #right-actions> + <slot name="delete-button"></slot> + </template> + </title-area> +</template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue new file mode 100644 index 00000000000..669adab9df6 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue @@ -0,0 +1,93 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; + +import { s__ } from '~/locale'; +import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue'; +import { + TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND, + TRACKING_ACTION_COPY_PYPI_SETUP_COMMAND, + TRACKING_LABEL_CODE_INSTRUCTION, +} from '~/packages_and_registries/package_registry/constants'; +import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; + +export default { + name: 'PyPiInstallation', + components: { + InstallationTitle, + CodeInstruction, + GlLink, + GlSprintf, + }, + inject: ['pypiHelpPath', 'pypiPath', 'pypiSetupPath'], + props: { + packageEntity: { + type: Object, + required: true, + }, + }, + computed: { + pypiPipCommand() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `pip install ${this.packageEntity.name} --extra-index-url ${this.pypiPath}`; + }, + pypiSetupCommand() { + return `[gitlab] +repository = ${this.pypiSetupPath} +username = __token__ +password = <your personal access token>`; + }, + }, + tracking: { + TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND, + TRACKING_ACTION_COPY_PYPI_SETUP_COMMAND, + TRACKING_LABEL_CODE_INSTRUCTION, + }, + i18n: { + setupText: s__( + `PackageRegistry|If you haven't already done so, you will need to add the below to your %{codeStart}.pypirc%{codeEnd} file.`, + ), + helpText: s__( + 'PackageRegistry|For more information on the PyPi registry, %{linkStart}see the documentation%{linkEnd}.', + ), + }, + installOptions: [{ value: 'pypi', label: s__('PackageRegistry|Show PyPi commands') }], +}; +</script> + +<template> + <div> + <installation-title package-type="pypi" :options="$options.installOptions" /> + + <code-instruction + :label="s__('PackageRegistry|Pip Command')" + :instruction="pypiPipCommand" + :copy-text="s__('PackageRegistry|Copy Pip command')" + data-testid="pip-command" + :tracking-action="$options.tracking.TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND" + :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION" + /> + + <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> + <p> + <gl-sprintf :message="$options.i18n.setupText"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + + <code-instruction + :instruction="pypiSetupCommand" + :copy-text="s__('PackageRegistry|Copy .pypirc content')" + data-testid="pypi-setup-content" + multiline + :tracking-action="$options.tracking.TRACKING_ACTION_COPY_PYPI_SETUP_COMMAND" + :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION" + /> + <gl-sprintf :message="$options.i18n.helpText"> + <template #link="{ content }"> + <gl-link :href="pypiHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue new file mode 100644 index 00000000000..d218a405af6 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue @@ -0,0 +1,71 @@ +<script> +import { GlLink, GlSprintf, GlTruncate } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import PackageTags from '~/packages/shared/components/package_tags.vue'; +import PublishMethod from '~/packages/shared/components/publish_method.vue'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { PACKAGE_DEFAULT_STATUS } from '../../constants'; + +export default { + name: 'PackageListRow', + components: { + GlLink, + GlSprintf, + GlTruncate, + PackageTags, + PublishMethod, + ListItem, + TimeAgoTooltip, + }, + props: { + packageEntity: { + type: Object, + required: true, + }, + }, + computed: { + packageLink() { + return `${getIdFromGraphQLId(this.packageEntity.id)}`; + }, + disabledRow() { + return this.packageEntity.status && this.packageEntity.status !== PACKAGE_DEFAULT_STATUS; + }, + }, +}; +</script> + +<template> + <list-item :disabled="disabledRow"> + <template #left-primary> + <div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0"> + <gl-link :href="packageLink" class="gl-text-body gl-min-w-0" :disabled="disabledRow"> + <gl-truncate :text="packageEntity.name" /> + </gl-link> + + <package-tags + v-if="packageEntity.tags.nodes && packageEntity.tags.nodes.length" + class="gl-ml-3" + :tags="packageEntity.tags.nodes" + hide-label + :tag-display-limit="1" + /> + </div> + </template> + <template #left-secondary> + {{ packageEntity.version }} + </template> + + <template #right-primary> + <publish-method :package-entity="packageEntity" /> + </template> + + <template #right-secondary> + <gl-sprintf :message="__('Created %{timestamp}')"> + <template #timestamp> + <time-ago-tooltip :time="packageEntity.createdAt" /> + </template> + </gl-sprintf> + </template> + </list-item> +</template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js new file mode 100644 index 00000000000..aad888b4433 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js @@ -0,0 +1,88 @@ +import { __, s__ } from '~/locale'; + +export const PACKAGE_TYPE_CONAN = 'CONAN'; +export const PACKAGE_TYPE_MAVEN = 'MAVEN'; +export const PACKAGE_TYPE_NPM = 'NPM'; +export const PACKAGE_TYPE_NUGET = 'NUGET'; +export const PACKAGE_TYPE_PYPI = 'PYPI'; +export const PACKAGE_TYPE_COMPOSER = 'COMPOSER'; +export const PACKAGE_TYPE_RUBYGEMS = 'RUBYGEMS'; +export const PACKAGE_TYPE_GENERIC = 'GENERIC'; +export const PACKAGE_TYPE_DEBIAN = 'DEBIAN'; +export const PACKAGE_TYPE_HELM = 'HELM'; + +export const DELETE_PACKAGE_TRACKING_ACTION = 'delete_package'; +export const REQUEST_DELETE_PACKAGE_TRACKING_ACTION = 'request_delete_package'; +export const CANCEL_DELETE_PACKAGE_TRACKING_ACTION = 'cancel_delete_package'; +export const PULL_PACKAGE_TRACKING_ACTION = 'pull_package'; +export const DELETE_PACKAGE_FILE_TRACKING_ACTION = 'delete_package_file'; +export const REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'request_delete_package_file'; +export const CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'cancel_delete_package_file'; + +export const TRACKING_LABEL_CODE_INSTRUCTION = 'code_instruction'; +export const TRACKING_LABEL_CONAN_INSTALLATION = 'conan_installation'; +export const TRACKING_LABEL_MAVEN_INSTALLATION = 'maven_installation'; +export const TRACKING_LABEL_NPM_INSTALLATION = 'npm_installation'; +export const TRACKING_LABEL_NUGET_INSTALLATION = 'nuget_installation'; +export const TRACKING_LABEL_PYPI_INSTALLATION = 'pypi_installation'; +export const TRACKING_LABEL_COMPOSER_INSTALLATION = 'composer_installation'; + +export const TRACKING_ACTION_INSTALLATION = 'installation'; +export const TRACKING_ACTION_REGISTRY_SETUP = 'registry_setup'; + +export const TRACKING_ACTION_COPY_CONAN_COMMAND = 'copy_conan_command'; +export const TRACKING_ACTION_COPY_CONAN_SETUP_COMMAND = 'copy_conan_setup_command'; + +export const TRACKING_ACTION_COPY_MAVEN_XML = 'copy_maven_xml'; +export const TRACKING_ACTION_COPY_MAVEN_COMMAND = 'copy_maven_command'; +export const TRACKING_ACTION_COPY_MAVEN_SETUP = 'copy_maven_setup_xml'; +export const TRACKING_ACTION_COPY_GRADLE_INSTALL_COMMAND = 'copy_gradle_install_command'; +export const TRACKING_ACTION_COPY_GRADLE_ADD_TO_SOURCE_COMMAND = + 'copy_gradle_add_to_source_command'; +export const TRACKING_ACTION_COPY_KOTLIN_INSTALL_COMMAND = 'copy_kotlin_install_command'; +export const TRACKING_ACTION_COPY_KOTLIN_ADD_TO_SOURCE_COMMAND = + 'copy_kotlin_add_to_source_command'; + +export const TRACKING_ACTION_COPY_NPM_INSTALL_COMMAND = 'copy_npm_install_command'; +export const TRACKING_ACTION_COPY_NPM_SETUP_COMMAND = 'copy_npm_setup_command'; +export const TRACKING_ACTION_COPY_YARN_INSTALL_COMMAND = 'copy_yarn_install_command'; +export const TRACKING_ACTION_COPY_YARN_SETUP_COMMAND = 'copy_yarn_setup_command'; + +export const TRACKING_ACTION_COPY_NUGET_INSTALL_COMMAND = 'copy_nuget_install_command'; +export const TRACKING_ACTION_COPY_NUGET_SETUP_COMMAND = 'copy_nuget_setup_command'; + +export const TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND = 'copy_pip_install_command'; +export const TRACKING_ACTION_COPY_PYPI_SETUP_COMMAND = 'copy_pypi_setup_command'; + +export const TRACKING_ACTION_COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND = + 'copy_composer_registry_include_command'; +export const TRACKING_ACTION_COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND = + 'copy_composer_package_include_command'; + +export const TrackingCategories = { + [PACKAGE_TYPE_MAVEN]: 'MavenPackages', + [PACKAGE_TYPE_NPM]: 'NpmPackages', + [PACKAGE_TYPE_CONAN]: 'ConanPackages', +}; + +export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert'; +export const DELETE_PACKAGE_ERROR_MESSAGE = s__( + 'PackageRegistry|Something went wrong while deleting the package.', +); +export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__( + __('PackageRegistry|Something went wrong while deleting the package file.'), +); +export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__( + 'PackageRegistry|Package file deleted successfully', +); +export const FETCH_PACKAGE_DETAILS_ERROR_MESSAGE = s__( + 'PackageRegistry|Failed to load the package data', +); + +export const PACKAGE_ERROR_STATUS = 'ERROR'; +export const PACKAGE_DEFAULT_STATUS = 'DEFAULT'; +export const PACKAGE_HIDDEN_STATUS = 'HIDDEN'; +export const PACKAGE_PROCESSING_STATUS = 'PROCESSING'; + +export const NPM_PACKAGE_MANAGER = 'npm'; +export const YARN_PACKAGE_MANAGER = 'yarn'; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragmentTypes.json b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragmentTypes.json new file mode 100644 index 00000000000..c61a653d10b --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragmentTypes.json @@ -0,0 +1,17 @@ +{ + "__schema": { + "types": [ + { + "kind": "UNION", + "name": "PackageMetadata", + "possibleTypes": [ + { "name": "ComposerMetadata" }, + { "name": "ConanMetadata" }, + { "name": "MavenMetadata" }, + { "name": "NugetMetadata" }, + { "name": "PypiMetadata" } + ] + } + ] + } +} diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js new file mode 100644 index 00000000000..f8cb5c516e2 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js @@ -0,0 +1,23 @@ +import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import introspectionQueryResultData from './fragmentTypes.json'; + +const fragmentMatcher = new IntrospectionFragmentMatcher({ + introspectionQueryResultData, +}); + +Vue.use(VueApollo); + +export const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + cacheConfig: { + fragmentMatcher, + }, + assumeImmutableResults: true, + }, + ), +}); diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql new file mode 100644 index 00000000000..884980f24a9 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql @@ -0,0 +1,5 @@ +mutation destroyPackage($id: PackagesPackageID!) { + destroyPackage(input: { id: $id }) { + errors + } +} diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql new file mode 100644 index 00000000000..f016640f57d --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql @@ -0,0 +1,5 @@ +mutation destroyPackageFile($id: PackagesPackageFileID!) { + destroyPackageFile(input: { id: $id }) { + errors + } +} diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql new file mode 100644 index 00000000000..14aa14e9822 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql @@ -0,0 +1,111 @@ +query getPackageDetails($id: ID!) { + package(id: $id) { + id + name + packageType + version + createdAt + updatedAt + status + project { + path + } + tags(first: 10) { + nodes { + id + name + } + } + pipelines(first: 10) { + nodes { + ref + id + sha + createdAt + commitPath + path + user { + name + } + project { + name + webUrl + } + } + } + packageFiles(first: 100) { + nodes { + id + fileMd5 + fileName + fileSha1 + fileSha256 + size + createdAt + downloadPath + } + } + versions(first: 100) { + nodes { + id + name + createdAt + version + status + tags(first: 1) { + nodes { + id + name + } + } + } + } + dependencyLinks { + nodes { + id + dependency { + id + name + versionPattern + } + dependencyType + metadata { + ... on NugetDependencyLinkMetadata { + id + targetFramework + } + } + } + } + metadata { + ... on ComposerMetadata { + targetSha + composerJson { + license + version + } + } + ... on PypiMetadata { + requiredPython + } + ... on ConanMetadata { + packageChannel + packageUsername + recipe + recipePath + } + ... on MavenMetadata { + appName + appGroup + appVersion + path + } + + ... on NugetMetadata { + iconUrl + licenseUrl + projectUrl + } + } + } +} diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.js b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.js index 309b35a8084..d94bbd21035 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.js @@ -1,7 +1,8 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; +import PackagesApp from '~/packages_and_registries/package_registry/components/details/app.vue'; +import { apolloProvider } from '~/packages_and_registries/package_registry/graphql/index'; import Translate from '~/vue_shared/translate'; -import PackagesApp from '../components/details/app.vue'; Vue.use(Translate); @@ -14,9 +15,9 @@ export default () => { const { canDelete, ...datasetOptions } = el.dataset; return new Vue({ el, + apolloProvider, provide: { canDelete: parseBoolean(canDelete), - titleComponent: 'PackageTitle', ...datasetOptions, }, render(createElement) { diff --git a/app/assets/javascripts/packages_and_registries/package_registry/utils.js b/app/assets/javascripts/packages_and_registries/package_registry/utils.js new file mode 100644 index 00000000000..ae886952c3e --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/utils.js @@ -0,0 +1,40 @@ +import { s__ } from '~/locale'; +import { + PACKAGE_TYPE_CONAN, + PACKAGE_TYPE_MAVEN, + PACKAGE_TYPE_NPM, + PACKAGE_TYPE_NUGET, + PACKAGE_TYPE_PYPI, + PACKAGE_TYPE_COMPOSER, + PACKAGE_TYPE_RUBYGEMS, + PACKAGE_TYPE_GENERIC, + PACKAGE_TYPE_DEBIAN, + PACKAGE_TYPE_HELM, +} from './constants'; + +export const getPackageTypeLabel = (packageType) => { + switch (packageType) { + case PACKAGE_TYPE_CONAN: + return s__('PackageRegistry|Conan'); + case PACKAGE_TYPE_MAVEN: + return s__('PackageRegistry|Maven'); + case PACKAGE_TYPE_NPM: + return s__('PackageRegistry|npm'); + case PACKAGE_TYPE_NUGET: + return s__('PackageRegistry|NuGet'); + case PACKAGE_TYPE_PYPI: + return s__('PackageRegistry|PyPI'); + case PACKAGE_TYPE_RUBYGEMS: + return s__('PackageRegistry|RubyGems'); + case PACKAGE_TYPE_COMPOSER: + return s__('PackageRegistry|Composer'); + case PACKAGE_TYPE_GENERIC: + return s__('PackageRegistry|Generic'); + case PACKAGE_TYPE_DEBIAN: + return s__('PackageRegistry|Debian'); + case PACKAGE_TYPE_HELM: + return s__('PackageRegistry|Helm'); + default: + return null; + } +}; diff --git a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue index 9850113d4be..c2510a16d2f 100644 --- a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue +++ b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue @@ -11,7 +11,7 @@ import { } from '@gitlab/ui'; import { toSafeInteger } from 'lodash'; import csrf from '~/lib/utils/csrf'; -import { __, s__, sprintf } from '~/locale'; +import { __, n__, s__, sprintf } from '~/locale'; import SignupCheckbox from './signup_checkbox.vue'; const DENYLIST_TYPE_RAW = 'raw'; @@ -51,6 +51,7 @@ export default { 'supportedSyntaxLinkUrl', 'emailRestrictions', 'afterSignUpText', + 'pendingUserCount', ], data() { return { @@ -105,8 +106,9 @@ export default { canUsersBeAccidentallyApproved() { const hasUserCapBeenToggledOff = this.requireAdminApprovalAfterUserSignup && !this.form.requireAdminApproval; + const currentlyPendingUsers = this.pendingUserCount > 0; - return this.hasUserCapBeenIncreased || hasUserCapBeenToggledOff; + return (this.hasUserCapBeenIncreased || hasUserCapBeenToggledOff) && currentlyPendingUsers; }, signupEnabledHelpText() { const text = sprintf( @@ -132,13 +134,39 @@ export default { return text; }, + approveUsersModal() { + const { pendingUserCount } = this; + + return { + id: 'signup-settings-modal', + text: n__( + 'ApplicationSettings|By making this change, you will automatically approve %d user with the pending approval status.', + 'ApplicationSettings|By making this change, you will automatically approve %d users with the pending approval status.', + pendingUserCount, + ), + actionPrimary: { + text: n__( + 'ApplicationSettings|Approve %d user', + 'ApplicationSettings|Approve %d users', + pendingUserCount, + ), + attributes: { + variant: 'confirm', + }, + }, + actionCancel: { + text: __('Cancel'), + }, + title: s__('ApplicationSettings|Approve users in the pending approval status?'), + }; + }, }, watch: { showModal(value) { if (value === true) { - this.$refs[this.$options.modal.id].show(); + this.$refs[this.approveUsersModal.id].show(); } else { - this.$refs[this.$options.modal.id].hide(); + this.$refs[this.approveUsersModal.id].hide(); } }, }, @@ -196,22 +224,6 @@ export default { afterSignUpTextGroupLabel: s__('ApplicationSettings|After sign up text'), afterSignUpTextGroupDescription: s__('ApplicationSettings|Markdown enabled'), }, - modal: { - id: 'signup-settings-modal', - actionPrimary: { - text: s__('ApplicationSettings|Approve users'), - attributes: { - variant: 'confirm', - }, - }, - actionCancel: { - text: __('Cancel'), - }, - title: s__('ApplicationSettings|Approve all users in the pending approval status?'), - text: s__( - 'ApplicationSettings|By making this change, you will automatically approve all users in pending approval status.', - ), - }, }; </script> @@ -403,15 +415,15 @@ export default { </gl-button> <gl-modal - :ref="$options.modal.id" - :modal-id="$options.modal.id" - :action-cancel="$options.modal.actionCancel" - :action-primary="$options.modal.actionPrimary" - :title="$options.modal.title" + :ref="approveUsersModal.id" + :modal-id="approveUsersModal.id" + :action-cancel="approveUsersModal.actionCancel" + :action-primary="approveUsersModal.actionPrimary" + :title="approveUsersModal.title" @primary="submitForm" @hide="modalHideHandler" > - {{ $options.modal.text }} + {{ approveUsersModal.text }} </gl-modal> </form> </template> diff --git a/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js index bf27b1a81ff..4c312a008cb 100644 --- a/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js +++ b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js @@ -8,34 +8,34 @@ export const HELPER_TEXT_SERVICE_PING_ENABLED = __( 'You can enable Registration Features because Service Ping is enabled. To continue using Registration Features in the future, you will also need to register with GitLab via a new cloud licensing service.', ); -function setHelperText(usagePingCheckbox) { +function setHelperText(servicePingCheckbox) { const helperTextId = document.getElementById('service_ping_features_helper_text'); - const usagePingFeaturesLabel = document.getElementById('service_ping_features_label'); + const servicePingFeaturesLabel = document.getElementById('service_ping_features_label'); - const usagePingFeaturesCheckbox = document.getElementById( + const servicePingFeaturesCheckbox = document.getElementById( 'application_setting_usage_ping_features_enabled', ); - helperTextId.textContent = usagePingCheckbox.checked + helperTextId.textContent = servicePingCheckbox.checked ? HELPER_TEXT_SERVICE_PING_ENABLED : HELPER_TEXT_SERVICE_PING_DISABLED; - usagePingFeaturesLabel.classList.toggle('gl-cursor-not-allowed', !usagePingCheckbox.checked); + servicePingFeaturesLabel.classList.toggle('gl-cursor-not-allowed', !servicePingCheckbox.checked); - usagePingFeaturesCheckbox.disabled = !usagePingCheckbox.checked; + servicePingFeaturesCheckbox.disabled = !servicePingCheckbox.checked; - if (!usagePingCheckbox.checked) { - usagePingFeaturesCheckbox.disabled = true; - usagePingFeaturesCheckbox.checked = false; + if (!servicePingCheckbox.checked) { + servicePingFeaturesCheckbox.disabled = true; + servicePingFeaturesCheckbox.checked = false; } } export default function initSetHelperText() { - const usagePingCheckbox = document.getElementById('application_setting_usage_ping_enabled'); + const servicePingCheckbox = document.getElementById('application_setting_usage_ping_enabled'); - setHelperText(usagePingCheckbox); - usagePingCheckbox.addEventListener('change', () => { - setHelperText(usagePingCheckbox); + setHelperText(servicePingCheckbox); + servicePingCheckbox.addEventListener('change', () => { + setHelperText(servicePingCheckbox); }); } diff --git a/app/assets/javascripts/pages/admin/groups/edit/index.js b/app/assets/javascripts/pages/admin/groups/edit/index.js index a4e5df559ff..01e03ed437d 100644 --- a/app/assets/javascripts/pages/admin/groups/edit/index.js +++ b/app/assets/javascripts/pages/admin/groups/edit/index.js @@ -1,3 +1,3 @@ import initFilePickers from '~/file_pickers'; -document.addEventListener('DOMContentLoaded', initFilePickers); +initFilePickers(); diff --git a/app/assets/javascripts/pages/admin/groups/show/index.js b/app/assets/javascripts/pages/admin/groups/show/index.js index 69d219d29f7..86b80a0ba5b 100644 --- a/app/assets/javascripts/pages/admin/groups/show/index.js +++ b/app/assets/javascripts/pages/admin/groups/show/index.js @@ -1,23 +1,3 @@ -import Vue from 'vue'; import UsersSelect from '~/users_select'; -import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; -function mountRemoveMemberModal() { - const el = document.querySelector('.js-remove-member-modal'); - if (!el) { - return false; - } - - return new Vue({ - el, - render(createComponent) { - return createComponent(RemoveMemberModal); - }, - }); -} - -document.addEventListener('DOMContentLoaded', () => { - mountRemoveMemberModal(); - - new UsersSelect(); // eslint-disable-line no-new -}); +new UsersSelect(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/admin/integrations/overrides/index.js b/app/assets/javascripts/pages/admin/integrations/overrides/index.js new file mode 100644 index 00000000000..b1504709144 --- /dev/null +++ b/app/assets/javascripts/pages/admin/integrations/overrides/index.js @@ -0,0 +1,3 @@ +import initIntegrationOverrides from '~/integrations/overrides'; + +initIntegrationOverrides(); diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue index ffccc1419a6..63b98f4143b 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue +++ b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue @@ -18,7 +18,7 @@ export default { computed: { text() { return s__( - 'AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running.', + 'AdminArea|You’re about to stop all jobs. This will halt all current jobs that are running.', ); }, }, diff --git a/app/assets/javascripts/pages/admin/projects/index.js b/app/assets/javascripts/pages/admin/projects/index.js index 042ff7808f1..b07ca815f13 100644 --- a/app/assets/javascripts/pages/admin/projects/index.js +++ b/app/assets/javascripts/pages/admin/projects/index.js @@ -1,23 +1,5 @@ -import Vue from 'vue'; import NamespaceSelect from '~/namespace_select'; import ProjectsList from '~/projects_list'; -import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; - -function mountRemoveMemberModal() { - const el = document.querySelector('.js-remove-member-modal'); - if (!el) { - return false; - } - - return new Vue({ - el, - render(createComponent) { - return createComponent(RemoveMemberModal); - }, - }); -} - -mountRemoveMemberModal(); new ProjectsList(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/admin/runners/index/index.js b/app/assets/javascripts/pages/admin/runners/index/index.js index d5563470394..f83111b6385 100644 --- a/app/assets/javascripts/pages/admin/runners/index/index.js +++ b/app/assets/javascripts/pages/admin/runners/index/index.js @@ -1,17 +1,3 @@ -import AdminRunnersFilteredSearchTokenKeys from '~/filtered_search/admin_runners_filtered_search_token_keys'; -import { FILTERED_SEARCH } from '~/pages/constants'; -import initFilteredSearch from '~/pages/search/init_filtered_search'; -import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; -import { initRunnerList } from '~/runner/runner_list'; +import { initAdminRunners } from '~/runner/admin_runners'; -initFilteredSearch({ - page: FILTERED_SEARCH.ADMIN_RUNNERS, - filteredSearchTokenKeys: AdminRunnersFilteredSearchTokenKeys, - useDefaultState: true, -}); - -initInstallRunner(); - -if (gon.features?.runnerListViewVueUi) { - initRunnerList(); -} +initAdminRunners(); diff --git a/app/assets/javascripts/pages/admin/serverless/domains/index.js b/app/assets/javascripts/pages/admin/serverless/domains/index.js index 5be466886a5..4fab7a1d9cb 100644 --- a/app/assets/javascripts/pages/admin/serverless/domains/index.js +++ b/app/assets/javascripts/pages/admin/serverless/domains/index.js @@ -1,19 +1,17 @@ import initSettingsPanels from '~/settings_panels'; -document.addEventListener('DOMContentLoaded', () => { - // Initialize expandable settings panels - initSettingsPanels(); +// Initialize expandable settings panels +initSettingsPanels(); - const domainCard = document.querySelector('.js-domain-cert-show'); - const domainForm = document.querySelector('.js-domain-cert-inputs'); - const domainReplaceButton = document.querySelector('.js-domain-cert-replace-btn'); - const domainSubmitButton = document.querySelector('.js-serverless-domain-submit'); +const domainCard = document.querySelector('.js-domain-cert-show'); +const domainForm = document.querySelector('.js-domain-cert-inputs'); +const domainReplaceButton = document.querySelector('.js-domain-cert-replace-btn'); +const domainSubmitButton = document.querySelector('.js-serverless-domain-submit'); - if (domainReplaceButton && domainCard && domainForm) { - domainReplaceButton.addEventListener('click', () => { - domainCard.classList.add('hidden'); - domainForm.classList.remove('hidden'); - domainSubmitButton.removeAttribute('disabled'); - }); - } -}); +if (domainReplaceButton && domainCard && domainForm) { + domainReplaceButton.addEventListener('click', () => { + domainCard.classList.add('hidden'); + domainForm.classList.remove('hidden'); + domainSubmitButton.removeAttribute('disabled'); + }); +} diff --git a/app/assets/javascripts/pages/dashboard/merge_requests/index.js b/app/assets/javascripts/pages/dashboard/merge_requests/index.js index b099165e3f5..6c134e4fad6 100644 --- a/app/assets/javascripts/pages/dashboard/merge_requests/index.js +++ b/app/assets/javascripts/pages/dashboard/merge_requests/index.js @@ -4,14 +4,12 @@ import { FILTERED_SEARCH } from '~/pages/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import projectSelect from '~/project_select'; -document.addEventListener('DOMContentLoaded', () => { - addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys, true); +addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys, true); - initFilteredSearch({ - page: FILTERED_SEARCH.MERGE_REQUESTS, - filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, - useDefaultState: true, - }); - - projectSelect(); +initFilteredSearch({ + page: FILTERED_SEARCH.MERGE_REQUESTS, + filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, + useDefaultState: true, }); + +projectSelect(); diff --git a/app/assets/javascripts/pages/dashboard/milestones/index/index.js b/app/assets/javascripts/pages/dashboard/milestones/index/index.js index 38ddebe30d9..b526fce6f7b 100644 --- a/app/assets/javascripts/pages/dashboard/milestones/index/index.js +++ b/app/assets/javascripts/pages/dashboard/milestones/index/index.js @@ -1,3 +1,3 @@ import projectSelect from '~/project_select'; -document.addEventListener('DOMContentLoaded', projectSelect); +projectSelect(); diff --git a/app/assets/javascripts/pages/explore/groups/index.js b/app/assets/javascripts/pages/explore/groups/index.js index 3c7edbdd7c7..808fcce46df 100644 --- a/app/assets/javascripts/pages/explore/groups/index.js +++ b/app/assets/javascripts/pages/explore/groups/index.js @@ -2,7 +2,7 @@ import GroupsList from '~/groups_list'; import Landing from '~/landing'; import initGroupsList from '../../../groups'; -document.addEventListener('DOMContentLoaded', () => { +function exploreGroups() { new GroupsList(); // eslint-disable-line no-new initGroupsList(); const landingElement = document.querySelector('.js-explore-groups-landing'); @@ -13,4 +13,6 @@ document.addEventListener('DOMContentLoaded', () => { 'explore_groups_landing_dismissed', ); exploreGroupsLanding.toggle(); -}); +} + +exploreGroups(); diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index 13656ee9b16..0137ff87979 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -1,4 +1,3 @@ -import Vue from 'vue'; import { groupMemberRequestFormatter } from '~/groups/members/utils'; import groupsSelect from '~/groups_select'; import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger'; @@ -11,21 +10,6 @@ import { initMembersApp } from '~/members'; import { MEMBER_TYPES } from '~/members/constants'; import { groupLinkRequestFormatter } from '~/members/utils'; import UsersSelect from '~/users_select'; -import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; - -function mountRemoveMemberModal() { - const el = document.querySelector('.js-remove-member-modal'); - if (!el) { - return false; - } - - return new Vue({ - el, - render(createComponent) { - return createComponent(RemoveMemberModal); - }, - }); -} const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions']; @@ -71,7 +55,6 @@ initMembersApp(document.querySelector('.js-group-members-list-app'), { groupsSelect(); memberExpirationDate(); memberExpirationDate('.js-access-expiration-date-groups'); -mountRemoveMemberModal(); initInviteMembersModal(); initInviteMembersTrigger(); initInviteGroupTrigger(); diff --git a/app/assets/javascripts/pages/groups/runners/index.js b/app/assets/javascripts/pages/groups/runners/index.js new file mode 100644 index 00000000000..ca1a6bdab75 --- /dev/null +++ b/app/assets/javascripts/pages/groups/runners/index.js @@ -0,0 +1,3 @@ +import { initGroupRunners } from '~/runner/group_runners'; + +initGroupRunners(); diff --git a/app/assets/javascripts/pages/groups/settings/badges/index.js b/app/assets/javascripts/pages/groups/settings/badges/index.js index 3f48e4f281e..9dcea737d51 100644 --- a/app/assets/javascripts/pages/groups/settings/badges/index.js +++ b/app/assets/javascripts/pages/groups/settings/badges/index.js @@ -5,6 +5,4 @@ import Translate from '~/vue_shared/translate'; Vue.use(Translate); -document.addEventListener('DOMContentLoaded', () => { - mountBadgeSettings(GROUP_BADGE); -}); +mountBadgeSettings(GROUP_BADGE); diff --git a/app/assets/javascripts/pages/help/index/index.js b/app/assets/javascripts/pages/help/index/index.js index fd8c590eb4e..736add8dca3 100644 --- a/app/assets/javascripts/pages/help/index/index.js +++ b/app/assets/javascripts/pages/help/index/index.js @@ -2,7 +2,5 @@ import $ from 'jquery'; import docs from '~/docs/docs_bundle'; import VersionCheckImage from '~/version_check_image'; -document.addEventListener('DOMContentLoaded', () => { - docs(); - VersionCheckImage.bindErrorEvent($('img.js-version-status-badge')); -}); +docs(); +VersionCheckImage.bindErrorEvent($('img.js-version-status-badge')); diff --git a/app/assets/javascripts/pages/help/ui/index.js b/app/assets/javascripts/pages/help/ui/index.js index 709ca2f3828..9ccc9123506 100644 --- a/app/assets/javascripts/pages/help/ui/index.js +++ b/app/assets/javascripts/pages/help/ui/index.js @@ -1,3 +1,3 @@ import initUIKit from '~/ui_development_kit'; -document.addEventListener('DOMContentLoaded', initUIKit); +initUIKit(); diff --git a/app/assets/javascripts/pages/import/bitbucket/status/index.js b/app/assets/javascripts/pages/import/bitbucket/status/index.js index f450a2aac00..6e9c26bf930 100644 --- a/app/assets/javascripts/pages/import/bitbucket/status/index.js +++ b/app/assets/javascripts/pages/import/bitbucket/status/index.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import { initStoreFromElement, initPropsFromElement } from '~/import_entities/import_projects'; import BitbucketStatusTable from '~/import_entities/import_projects/components/bitbucket_status_table.vue'; -document.addEventListener('DOMContentLoaded', () => { +function importBitBucket() { const mountElement = document.getElementById('import-projects-mount-element'); if (!mountElement) return undefined; @@ -16,4 +16,6 @@ document.addEventListener('DOMContentLoaded', () => { return createElement(BitbucketStatusTable, { attrs }); }, }); -}); +} + +importBitBucket(); diff --git a/app/assets/javascripts/pages/import/bitbucket_server/status/index.js b/app/assets/javascripts/pages/import/bitbucket_server/status/index.js index a6d748ce857..90eb423c7a7 100644 --- a/app/assets/javascripts/pages/import/bitbucket_server/status/index.js +++ b/app/assets/javascripts/pages/import/bitbucket_server/status/index.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import { initStoreFromElement, initPropsFromElement } from '~/import_entities/import_projects'; import BitbucketServerStatusTable from './components/bitbucket_server_status_table.vue'; -document.addEventListener('DOMContentLoaded', () => { +function BitbucketServerStatus() { const mountElement = document.getElementById('import-projects-mount-element'); if (!mountElement) return undefined; @@ -19,4 +19,6 @@ document.addEventListener('DOMContentLoaded', () => { }); }, }); -}); +} + +BitbucketServerStatus(); diff --git a/app/assets/javascripts/pages/import/fogbugz/new_user_map/index.js b/app/assets/javascripts/pages/import/fogbugz/new_user_map/index.js index 68d4c1f049f..86b80a0ba5b 100644 --- a/app/assets/javascripts/pages/import/fogbugz/new_user_map/index.js +++ b/app/assets/javascripts/pages/import/fogbugz/new_user_map/index.js @@ -1,3 +1,3 @@ import UsersSelect from '~/users_select'; -document.addEventListener('DOMContentLoaded', () => new UsersSelect()); +new UsersSelect(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/import/fogbugz/status/index.js b/app/assets/javascripts/pages/import/fogbugz/status/index.js index 98ddb8b3aa4..4c427b72372 100644 --- a/app/assets/javascripts/pages/import/fogbugz/status/index.js +++ b/app/assets/javascripts/pages/import/fogbugz/status/index.js @@ -1,7 +1,5 @@ import mountImportProjectsTable from '~/import_entities/import_projects'; -document.addEventListener('DOMContentLoaded', () => { - const mountElement = document.getElementById('import-projects-mount-element'); +const mountElement = document.getElementById('import-projects-mount-element'); - mountImportProjectsTable(mountElement); -}); +mountImportProjectsTable(mountElement); diff --git a/app/assets/javascripts/pages/import/gitea/status/index.js b/app/assets/javascripts/pages/import/gitea/status/index.js index 98ddb8b3aa4..4c427b72372 100644 --- a/app/assets/javascripts/pages/import/gitea/status/index.js +++ b/app/assets/javascripts/pages/import/gitea/status/index.js @@ -1,7 +1,5 @@ import mountImportProjectsTable from '~/import_entities/import_projects'; -document.addEventListener('DOMContentLoaded', () => { - const mountElement = document.getElementById('import-projects-mount-element'); +const mountElement = document.getElementById('import-projects-mount-element'); - mountImportProjectsTable(mountElement); -}); +mountImportProjectsTable(mountElement); diff --git a/app/assets/javascripts/pages/import/github/status/index.js b/app/assets/javascripts/pages/import/github/status/index.js index 98ddb8b3aa4..4c427b72372 100644 --- a/app/assets/javascripts/pages/import/github/status/index.js +++ b/app/assets/javascripts/pages/import/github/status/index.js @@ -1,7 +1,5 @@ import mountImportProjectsTable from '~/import_entities/import_projects'; -document.addEventListener('DOMContentLoaded', () => { - const mountElement = document.getElementById('import-projects-mount-element'); +const mountElement = document.getElementById('import-projects-mount-element'); - mountImportProjectsTable(mountElement); -}); +mountImportProjectsTable(mountElement); diff --git a/app/assets/javascripts/pages/import/gitlab/status/index.js b/app/assets/javascripts/pages/import/gitlab/status/index.js index 98ddb8b3aa4..4c427b72372 100644 --- a/app/assets/javascripts/pages/import/gitlab/status/index.js +++ b/app/assets/javascripts/pages/import/gitlab/status/index.js @@ -1,7 +1,5 @@ import mountImportProjectsTable from '~/import_entities/import_projects'; -document.addEventListener('DOMContentLoaded', () => { - const mountElement = document.getElementById('import-projects-mount-element'); +const mountElement = document.getElementById('import-projects-mount-element'); - mountImportProjectsTable(mountElement); -}); +mountImportProjectsTable(mountElement); diff --git a/app/assets/javascripts/pages/import/gitlab_projects/new/index.js b/app/assets/javascripts/pages/import/gitlab_projects/new/index.js index bb86f72b95b..870c14f99ae 100644 --- a/app/assets/javascripts/pages/import/gitlab_projects/new/index.js +++ b/app/assets/javascripts/pages/import/gitlab_projects/new/index.js @@ -1,3 +1,3 @@ import initGitLabImportProject from '~/projects/project_import_gitlab_project'; -document.addEventListener('DOMContentLoaded', initGitLabImportProject); +initGitLabImportProject(); diff --git a/app/assets/javascripts/pages/import/manifest/status/index.js b/app/assets/javascripts/pages/import/manifest/status/index.js index 98ddb8b3aa4..4c427b72372 100644 --- a/app/assets/javascripts/pages/import/manifest/status/index.js +++ b/app/assets/javascripts/pages/import/manifest/status/index.js @@ -1,7 +1,5 @@ import mountImportProjectsTable from '~/import_entities/import_projects'; -document.addEventListener('DOMContentLoaded', () => { - const mountElement = document.getElementById('import-projects-mount-element'); +const mountElement = document.getElementById('import-projects-mount-element'); - mountImportProjectsTable(mountElement); -}); +mountImportProjectsTable(mountElement); diff --git a/app/assets/javascripts/pages/jira_connect/branches/new/index.js b/app/assets/javascripts/pages/jira_connect/branches/new/index.js new file mode 100644 index 00000000000..f8c3ec63f1f --- /dev/null +++ b/app/assets/javascripts/pages/jira_connect/branches/new/index.js @@ -0,0 +1,3 @@ +import initJiraConnectBranches from '~/jira_connect/branches'; + +initJiraConnectBranches(); diff --git a/app/assets/javascripts/pages/ldap/omniauth_callbacks/index.js b/app/assets/javascripts/pages/ldap/omniauth_callbacks/index.js index e93def5323f..8d8534ec556 100644 --- a/app/assets/javascripts/pages/ldap/omniauth_callbacks/index.js +++ b/app/assets/javascripts/pages/ldap/omniauth_callbacks/index.js @@ -1,3 +1,3 @@ import { mount2faAuthentication } from '~/authentication/mount_2fa'; -document.addEventListener('DOMContentLoaded', mount2faAuthentication); +mount2faAuthentication(); diff --git a/app/assets/javascripts/pages/omniauth_callbacks/index.js b/app/assets/javascripts/pages/omniauth_callbacks/index.js index e93def5323f..8d8534ec556 100644 --- a/app/assets/javascripts/pages/omniauth_callbacks/index.js +++ b/app/assets/javascripts/pages/omniauth_callbacks/index.js @@ -1,3 +1,3 @@ import { mount2faAuthentication } from '~/authentication/mount_2fa'; -document.addEventListener('DOMContentLoaded', mount2faAuthentication); +mount2faAuthentication(); diff --git a/app/assets/javascripts/pages/profiles/accounts/show/index.js b/app/assets/javascripts/pages/profiles/accounts/show/index.js index 5350ef61184..3d400ed77f5 100644 --- a/app/assets/javascripts/pages/profiles/accounts/show/index.js +++ b/app/assets/javascripts/pages/profiles/accounts/show/index.js @@ -1,6 +1,6 @@ import { initClose2faSuccessMessage } from '~/authentication/two_factor_auth'; import initProfileAccount from '~/profile/account'; -document.addEventListener('DOMContentLoaded', initProfileAccount); +initProfileAccount(); initClose2faSuccessMessage(); diff --git a/app/assets/javascripts/pages/profiles/keys/index.js b/app/assets/javascripts/pages/profiles/keys/index.js index 4214d5bffb2..1b291d9509d 100644 --- a/app/assets/javascripts/pages/profiles/keys/index.js +++ b/app/assets/javascripts/pages/profiles/keys/index.js @@ -1,9 +1,9 @@ import initConfirmModal from '~/confirm_modal'; import AddSshKeyValidation from '~/profile/add_ssh_key_validation'; -document.addEventListener('DOMContentLoaded', () => { - initConfirmModal(); +initConfirmModal(); +function initSshKeyValidation() { const input = document.querySelector('.js-add-ssh-key-validation-input'); if (!input) return; @@ -18,4 +18,6 @@ document.addEventListener('DOMContentLoaded', () => { confirmSubmit, ); addSshKeyValidation.register(); -}); +} + +initSshKeyValidation(); diff --git a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js index 186072531b8..50835333a54 100644 --- a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js +++ b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js @@ -2,17 +2,15 @@ import { mount2faRegistration } from '~/authentication/mount_2fa'; import { initRecoveryCodes } from '~/authentication/two_factor_auth'; import { parseBoolean } from '~/lib/utils/common_utils'; -document.addEventListener('DOMContentLoaded', () => { - const twoFactorNode = document.querySelector('.js-two-factor-auth'); - const skippable = twoFactorNode ? parseBoolean(twoFactorNode.dataset.twoFactorSkippable) : false; +const twoFactorNode = document.querySelector('.js-two-factor-auth'); +const skippable = twoFactorNode ? parseBoolean(twoFactorNode.dataset.twoFactorSkippable) : false; - if (skippable) { - const button = `<a class="btn btn-sm btn-warning float-right" data-qa-selector="configure_it_later_button" data-method="patch" href="${twoFactorNode.dataset.two_factor_skip_url}">Configure it later</a>`; - const flashAlert = document.querySelector('.flash-alert'); - if (flashAlert) flashAlert.insertAdjacentHTML('beforeend', button); - } +if (skippable) { + const button = `<a class="btn btn-sm btn-warning float-right" data-qa-selector="configure_it_later_button" data-method="patch" href="${twoFactorNode.dataset.two_factor_skip_url}">Configure it later</a>`; + const flashAlert = document.querySelector('.flash-alert'); + if (flashAlert) flashAlert.insertAdjacentHTML('beforeend', button); +} - mount2faRegistration(); -}); +mount2faRegistration(); initRecoveryCodes(); diff --git a/app/assets/javascripts/pages/projects/artifacts/file/index.js b/app/assets/javascripts/pages/projects/artifacts/file/index.js index 057ef157374..07ee4d686cc 100644 --- a/app/assets/javascripts/pages/projects/artifacts/file/index.js +++ b/app/assets/javascripts/pages/projects/artifacts/file/index.js @@ -1,5 +1,5 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -import BlobViewer from '~/blob/viewer/index'; +import { BlobViewer } from '~/blob/viewer/index'; new ShortcutsNavigation(); // eslint-disable-line no-new new BlobViewer(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 6cc0095f5a5..b365e039191 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import TableOfContents from '~/blob/components/table_contents.vue'; import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue'; -import BlobViewer from '~/blob/viewer/index'; +import { BlobViewer, initAuxiliaryViewer } from '~/blob/viewer/index'; import GpgBadges from '~/gpg_badges'; import createDefaultClient from '~/lib/graphql'; import initBlob from '~/pages/projects/init_blob'; @@ -39,6 +39,8 @@ if (viewBlobEl) { }); }, }); + + initAuxiliaryViewer(); } else { new BlobViewer(); // eslint-disable-line no-new initBlob(); diff --git a/app/assets/javascripts/pages/projects/compare/show/index.js b/app/assets/javascripts/pages/projects/compare/show/index.js index 549e596cb8d..5edaa7f7e51 100644 --- a/app/assets/javascripts/pages/projects/compare/show/index.js +++ b/app/assets/javascripts/pages/projects/compare/show/index.js @@ -5,9 +5,7 @@ import initCompareSelector from '~/projects/compare'; initCompareSelector(); -document.addEventListener('DOMContentLoaded', () => { - new Diff(); // eslint-disable-line no-new - const paddingTop = 16; - initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight - paddingTop); - GpgBadges.fetch(); -}); +new Diff(); // eslint-disable-line no-new +const paddingTop = 16; +initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight - paddingTop); +GpgBadges.fetch(); diff --git a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js index 255d05b39be..bef21ef8fdf 100644 --- a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js +++ b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js @@ -1,3 +1,3 @@ import initCycleAnalytics from '~/cycle_analytics'; -document.addEventListener('DOMContentLoaded', initCycleAnalytics); +initCycleAnalytics(); diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js index 9aa7e62e3ee..335d8d481fc 100644 --- a/app/assets/javascripts/pages/projects/edit/index.js +++ b/app/assets/javascripts/pages/projects/edit/index.js @@ -25,10 +25,6 @@ initProjectLoadingSpinner(); initProjectPermissionsSettings(); setupTransferEdit('.js-project-transfer-form', 'select.select2'); -dirtySubmitFactory( - document.querySelectorAll( - '.js-general-settings-form, .js-mr-settings-form, .js-mr-approvals-form', - ), -); +dirtySubmitFactory(document.querySelectorAll('.js-general-settings-form, .js-mr-settings-form')); initSearchSettings(); diff --git a/app/assets/javascripts/pages/projects/environments/edit/index.js b/app/assets/javascripts/pages/projects/environments/edit/index.js new file mode 100644 index 00000000000..574963d825a --- /dev/null +++ b/app/assets/javascripts/pages/projects/environments/edit/index.js @@ -0,0 +1,3 @@ +import mountEdit from '~/environments/edit'; + +mountEdit(document.getElementById('js-edit-environment')); diff --git a/app/assets/javascripts/pages/projects/environments/folder/index.js b/app/assets/javascripts/pages/projects/environments/folder/index.js index 5feaf944038..2f22a3a84ff 100644 --- a/app/assets/javascripts/pages/projects/environments/folder/index.js +++ b/app/assets/javascripts/pages/projects/environments/folder/index.js @@ -1,3 +1,3 @@ import initEnvironmentsFolderBundle from '~/environments/folder/environments_folder_bundle'; -document.addEventListener('DOMContentLoaded', initEnvironmentsFolderBundle); +initEnvironmentsFolderBundle(); diff --git a/app/assets/javascripts/pages/projects/environments/metrics/index.js b/app/assets/javascripts/pages/projects/environments/metrics/index.js index d3028aec313..606439866ea 100644 --- a/app/assets/javascripts/pages/projects/environments/metrics/index.js +++ b/app/assets/javascripts/pages/projects/environments/metrics/index.js @@ -1,3 +1,3 @@ import monitoringApp from '~/monitoring/monitoring_app'; -document.addEventListener('DOMContentLoaded', monitoringApp); +monitoringApp(); diff --git a/app/assets/javascripts/pages/projects/environments/new/index.js b/app/assets/javascripts/pages/projects/environments/new/index.js new file mode 100644 index 00000000000..2edb1ca7088 --- /dev/null +++ b/app/assets/javascripts/pages/projects/environments/new/index.js @@ -0,0 +1,3 @@ +import mountNew from '~/environments/new'; + +mountNew(document.getElementById('js-new-environment')); diff --git a/app/assets/javascripts/pages/projects/environments/show/index.js b/app/assets/javascripts/pages/projects/environments/show/index.js index a4960037eaa..53e48ad8d86 100644 --- a/app/assets/javascripts/pages/projects/environments/show/index.js +++ b/app/assets/javascripts/pages/projects/environments/show/index.js @@ -1,3 +1,5 @@ -import initShowEnvironment from '~/environments/mount_show'; +import initConfirmRollBackModal from '~/environments/init_confirm_rollback_modal'; +import { initHeader } from '~/environments/mount_show'; -initShowEnvironment(); +initHeader(); +initConfirmRollBackModal(); diff --git a/app/assets/javascripts/pages/projects/environments/terminal/index.js b/app/assets/javascripts/pages/projects/environments/terminal/index.js index 7129e24cee1..d42c163a41b 100644 --- a/app/assets/javascripts/pages/projects/environments/terminal/index.js +++ b/app/assets/javascripts/pages/projects/environments/terminal/index.js @@ -1,3 +1,3 @@ import initTerminal from '~/terminal/'; -document.addEventListener('DOMContentLoaded', initTerminal); +initTerminal(); diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js index ea38b8e15a4..c217bc5a727 100644 --- a/app/assets/javascripts/pages/projects/graphs/charts/index.js +++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js @@ -7,151 +7,149 @@ import SeriesDataMixin from './series_data_mixin'; const seriesDataToBarData = (raw) => Object.entries(raw).map(([name, data]) => ({ name, data })); -document.addEventListener('DOMContentLoaded', () => { - waitForCSSLoaded(() => { - const languagesContainer = document.getElementById('js-languages-chart'); - const codeCoverageContainer = document.getElementById('js-code-coverage-chart'); - const monthContainer = document.getElementById('js-month-chart'); - const weekdayContainer = document.getElementById('js-weekday-chart'); - const hourContainer = document.getElementById('js-hour-chart'); - const LANGUAGE_CHART_HEIGHT = 300; - const reorderWeekDays = (weekDays, firstDayOfWeek = 0) => { - if (firstDayOfWeek === 0) { - return weekDays; - } +waitForCSSLoaded(() => { + const languagesContainer = document.getElementById('js-languages-chart'); + const codeCoverageContainer = document.getElementById('js-code-coverage-chart'); + const monthContainer = document.getElementById('js-month-chart'); + const weekdayContainer = document.getElementById('js-weekday-chart'); + const hourContainer = document.getElementById('js-hour-chart'); + const LANGUAGE_CHART_HEIGHT = 300; + const reorderWeekDays = (weekDays, firstDayOfWeek = 0) => { + if (firstDayOfWeek === 0) { + return weekDays; + } - return Object.keys(weekDays).reduce((acc, dayName, idx, arr) => { - const reorderedDayName = arr[(idx + firstDayOfWeek) % arr.length]; + return Object.keys(weekDays).reduce((acc, dayName, idx, arr) => { + const reorderedDayName = arr[(idx + firstDayOfWeek) % arr.length]; - return { - ...acc, - [reorderedDayName]: weekDays[reorderedDayName], - }; - }, {}); - }; + return { + ...acc, + [reorderedDayName]: weekDays[reorderedDayName], + }; + }, {}); + }; - // eslint-disable-next-line no-new - new Vue({ - el: languagesContainer, - components: { - GlColumnChart, + // eslint-disable-next-line no-new + new Vue({ + el: languagesContainer, + components: { + GlColumnChart, + }, + data() { + return { + chartData: JSON.parse(languagesContainer.dataset.chartData), + }; + }, + computed: { + seriesData() { + return [{ name: 'full', data: this.chartData.map((d) => [d.label, d.value]) }]; }, - data() { - return { - chartData: JSON.parse(languagesContainer.dataset.chartData), - }; - }, - computed: { - seriesData() { - return [{ name: 'full', data: this.chartData.map((d) => [d.label, d.value]) }]; + }, + render(h) { + return h(GlColumnChart, { + props: { + bars: this.seriesData, + xAxisTitle: __('Used programming language'), + yAxisTitle: __('Percentage'), + xAxisType: 'category', }, - }, - render(h) { - return h(GlColumnChart, { - props: { - bars: this.seriesData, - xAxisTitle: __('Used programming language'), - yAxisTitle: __('Percentage'), - xAxisType: 'category', - }, - attrs: { - height: LANGUAGE_CHART_HEIGHT, - }, - }); - }, - }); + attrs: { + height: LANGUAGE_CHART_HEIGHT, + }, + }); + }, + }); - // eslint-disable-next-line no-new - new Vue({ - el: codeCoverageContainer, - render(h) { - return h(CodeCoverage, { - props: { - graphEndpoint: codeCoverageContainer.dataset?.graphEndpoint, - }, - }); - }, - }); + // eslint-disable-next-line no-new + new Vue({ + el: codeCoverageContainer, + render(h) { + return h(CodeCoverage, { + props: { + graphEndpoint: codeCoverageContainer.dataset?.graphEndpoint, + }, + }); + }, + }); - // eslint-disable-next-line no-new - new Vue({ - el: monthContainer, - components: { - GlColumnChart, - }, - mixins: [SeriesDataMixin], - data() { - return { - chartData: JSON.parse(monthContainer.dataset.chartData), - }; - }, - render(h) { - return h(GlColumnChart, { - props: { - bars: seriesDataToBarData(this.seriesData), - xAxisTitle: __('Day of month'), - yAxisTitle: __('No. of commits'), - xAxisType: 'category', - }, - }); - }, - }); + // eslint-disable-next-line no-new + new Vue({ + el: monthContainer, + components: { + GlColumnChart, + }, + mixins: [SeriesDataMixin], + data() { + return { + chartData: JSON.parse(monthContainer.dataset.chartData), + }; + }, + render(h) { + return h(GlColumnChart, { + props: { + bars: seriesDataToBarData(this.seriesData), + xAxisTitle: __('Day of month'), + yAxisTitle: __('No. of commits'), + xAxisType: 'category', + }, + }); + }, + }); - // eslint-disable-next-line no-new - new Vue({ - el: weekdayContainer, - components: { - GlColumnChart, - }, - data() { - return { - chartData: JSON.parse(weekdayContainer.dataset.chartData), - }; + // eslint-disable-next-line no-new + new Vue({ + el: weekdayContainer, + components: { + GlColumnChart, + }, + data() { + return { + chartData: JSON.parse(weekdayContainer.dataset.chartData), + }; + }, + computed: { + seriesData() { + const weekDays = reorderWeekDays(this.chartData, gon.first_day_of_week); + const data = Object.keys(weekDays).reduce((acc, key) => { + acc.push([key, weekDays[key]]); + return acc; + }, []); + return [{ name: 'full', data }]; }, - computed: { - seriesData() { - const weekDays = reorderWeekDays(this.chartData, gon.first_day_of_week); - const data = Object.keys(weekDays).reduce((acc, key) => { - acc.push([key, weekDays[key]]); - return acc; - }, []); - return [{ name: 'full', data }]; + }, + render(h) { + return h(GlColumnChart, { + props: { + bars: this.seriesData, + xAxisTitle: __('Weekday'), + yAxisTitle: __('No. of commits'), + xAxisType: 'category', }, - }, - render(h) { - return h(GlColumnChart, { - props: { - bars: this.seriesData, - xAxisTitle: __('Weekday'), - yAxisTitle: __('No. of commits'), - xAxisType: 'category', - }, - }); - }, - }); + }); + }, + }); - // eslint-disable-next-line no-new - new Vue({ - el: hourContainer, - components: { - GlColumnChart, - }, - mixins: [SeriesDataMixin], - data() { - return { - chartData: JSON.parse(hourContainer.dataset.chartData), - }; - }, - render(h) { - return h(GlColumnChart, { - props: { - bars: seriesDataToBarData(this.seriesData), - xAxisTitle: __('Hour (UTC)'), - yAxisTitle: __('No. of commits'), - xAxisType: 'category', - }, - }); - }, - }); + // eslint-disable-next-line no-new + new Vue({ + el: hourContainer, + components: { + GlColumnChart, + }, + mixins: [SeriesDataMixin], + data() { + return { + chartData: JSON.parse(hourContainer.dataset.chartData), + }; + }, + render(h) { + return h(GlColumnChart, { + props: { + bars: seriesDataToBarData(this.seriesData), + xAxisTitle: __('Hour (UTC)'), + yAxisTitle: __('No. of commits'), + xAxisType: 'category', + }, + }); + }, }); }); diff --git a/app/assets/javascripts/pages/projects/graphs/show/index.js b/app/assets/javascripts/pages/projects/graphs/show/index.js index 09d9c78c446..4f5a5bfe6fe 100644 --- a/app/assets/javascripts/pages/projects/graphs/show/index.js +++ b/app/assets/javascripts/pages/projects/graphs/show/index.js @@ -1,3 +1,3 @@ import initContributorsGraphs from '~/contributors'; -document.addEventListener('DOMContentLoaded', initContributorsGraphs); +initContributorsGraphs(); diff --git a/app/assets/javascripts/pages/projects/import/jira/index.js b/app/assets/javascripts/pages/projects/import/jira/index.js index cb7a7bde55d..5876e5283b5 100644 --- a/app/assets/javascripts/pages/projects/import/jira/index.js +++ b/app/assets/javascripts/pages/projects/import/jira/index.js @@ -1,3 +1,3 @@ import mountJiraImportApp from '~/jira_import'; -document.addEventListener('DOMContentLoaded', mountJiraImportApp); +mountJiraImportApp(); diff --git a/app/assets/javascripts/pages/projects/issues/edit/index.js b/app/assets/javascripts/pages/projects/issues/edit/index.js index aecc6484b26..48afd2142ee 100644 --- a/app/assets/javascripts/pages/projects/issues/edit/index.js +++ b/app/assets/javascripts/pages/projects/issues/edit/index.js @@ -1,3 +1,3 @@ import initForm from 'ee_else_ce/pages/projects/issues/form'; -document.addEventListener('DOMContentLoaded', initForm); +initForm(); diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js index 3cea61262ea..e365f51567d 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -3,10 +3,10 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import initIssuableSidebar from '~/init_issuable_sidebar'; import { IssuableType } from '~/issuable_show/constants'; import Issue from '~/issue'; -import '~/notes/index'; import initIncidentApp from '~/issue_show/incident'; import { initIssuableApp, initIssueHeaderActions } from '~/issue_show/issue'; import { parseIssuableData } from '~/issue_show/utils/parse_data'; +import initNotesApp from '~/notes/index'; import { store } from '~/notes/stores'; import initRelatedMergeRequestsApp from '~/related_merge_requests'; import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace'; @@ -14,6 +14,8 @@ import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_iss import ZenMode from '~/zen_mode'; export default function initShowIssue() { + initNotesApp(); + const initialDataEl = document.getElementById('js-issuable-app'); const { issueType, ...issuableData } = parseIssuableData(initialDataEl); diff --git a/app/assets/javascripts/pages/projects/jobs/terminal/index.js b/app/assets/javascripts/pages/projects/jobs/terminal/index.js index 7129e24cee1..d42c163a41b 100644 --- a/app/assets/javascripts/pages/projects/jobs/terminal/index.js +++ b/app/assets/javascripts/pages/projects/jobs/terminal/index.js @@ -1,3 +1,3 @@ import initTerminal from '~/terminal/'; -document.addEventListener('DOMContentLoaded', initTerminal); +initTerminal(); diff --git a/app/assets/javascripts/pages/projects/network/show/index.js b/app/assets/javascripts/pages/projects/network/show/index.js index 9f05f63b742..2dabcfadfab 100644 --- a/app/assets/javascripts/pages/projects/network/show/index.js +++ b/app/assets/javascripts/pages/projects/network/show/index.js @@ -2,7 +2,7 @@ import $ from 'jquery'; import ShortcutsNetwork from '~/behaviors/shortcuts/shortcuts_network'; import Network from '../network'; -document.addEventListener('DOMContentLoaded', () => { +(() => { if (!$('.network-graph').length) return; const networkGraph = new Network({ @@ -14,4 +14,4 @@ document.addEventListener('DOMContentLoaded', () => { // eslint-disable-next-line no-new new ShortcutsNetwork(networkGraph.branch_graph); -}); +})(); diff --git a/app/assets/javascripts/pages/projects/pages_domains/new/index.js b/app/assets/javascripts/pages/projects/pages_domains/new/index.js index 27e4433ad4d..17fa49a46e0 100644 --- a/app/assets/javascripts/pages/projects/pages_domains/new/index.js +++ b/app/assets/javascripts/pages/projects/pages_domains/new/index.js @@ -1,3 +1,3 @@ import initForm from '~/pages/projects/pages_domains/form'; -document.addEventListener('DOMContentLoaded', initForm); +initForm(); diff --git a/app/assets/javascripts/pages/projects/pages_domains/show/index.js b/app/assets/javascripts/pages/projects/pages_domains/show/index.js index 27e4433ad4d..17fa49a46e0 100644 --- a/app/assets/javascripts/pages/projects/pages_domains/show/index.js +++ b/app/assets/javascripts/pages/projects/pages_domains/show/index.js @@ -1,3 +1,3 @@ import initForm from '~/pages/projects/pages_domains/form'; -document.addEventListener('DOMContentLoaded', initForm); +initForm(); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js index 40730ec7e60..cd4bc35e74e 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import PipelineSchedulesCallout from '../shared/components/pipeline_schedules_callout.vue'; -document.addEventListener('DOMContentLoaded', () => { +function initPipelineSchedules() { const el = document.getElementById('pipeline-schedules-callout'); if (!el) { @@ -21,4 +21,6 @@ document.addEventListener('DOMContentLoaded', () => { return createElement(PipelineSchedulesCallout); }, }); -}); +} + +initPipelineSchedules(); diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js index 177dc346c60..fb0be31834d 100644 --- a/app/assets/javascripts/pages/projects/project_members/index.js +++ b/app/assets/javascripts/pages/projects/project_members/index.js @@ -1,4 +1,3 @@ -import Vue from 'vue'; import groupsSelect from '~/groups_select'; import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger'; import initInviteMembersForm from '~/invite_members/init_invite_members_form'; @@ -11,26 +10,10 @@ import { MEMBER_TYPES } from '~/members/constants'; import { groupLinkRequestFormatter } from '~/members/utils'; import { projectMemberRequestFormatter } from '~/projects/members/utils'; import UsersSelect from '~/users_select'; -import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; - -function mountRemoveMemberModal() { - const el = document.querySelector('.js-remove-member-modal'); - if (!el) { - return false; - } - - return new Vue({ - el, - render(createComponent) { - return createComponent(RemoveMemberModal); - }, - }); -} groupsSelect(); memberExpirationDate(); memberExpirationDate('.js-access-expiration-date-groups'); -mountRemoveMemberModal(); initInviteMembersModal(); initInviteMembersTrigger(); initInviteGroupTrigger(); diff --git a/app/assets/javascripts/pages/projects/security/configuration/index.js b/app/assets/javascripts/pages/projects/security/configuration/index.js index 8bba3d7af54..5f801501b2f 100644 --- a/app/assets/javascripts/pages/projects/security/configuration/index.js +++ b/app/assets/javascripts/pages/projects/security/configuration/index.js @@ -1,3 +1,3 @@ -import { initCESecurityConfiguration } from '~/security_configuration'; +import { initSecurityConfiguration } from '~/security_configuration'; -initCESecurityConfiguration(document.querySelector('#js-security-configuration-static')); +initSecurityConfiguration(document.querySelector('#js-security-configuration-static')); diff --git a/app/assets/javascripts/pages/projects/services/edit/index.js b/app/assets/javascripts/pages/projects/services/edit/index.js index 8e603c5c1a2..03ffc323fc0 100644 --- a/app/assets/javascripts/pages/projects/services/edit/index.js +++ b/app/assets/javascripts/pages/projects/services/edit/index.js @@ -2,16 +2,14 @@ import IntegrationSettingsForm from '~/integrations/integration_settings_form'; import PrometheusAlerts from '~/prometheus_alerts'; import CustomMetrics from '~/prometheus_metrics/custom_metrics'; -document.addEventListener('DOMContentLoaded', () => { - const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); - integrationSettingsForm.init(); +const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); +integrationSettingsForm.init(); - const prometheusSettingsSelector = '.js-prometheus-metrics-monitoring'; - const prometheusSettingsWrapper = document.querySelector(prometheusSettingsSelector); - if (prometheusSettingsWrapper) { - const customMetrics = new CustomMetrics(prometheusSettingsSelector); - customMetrics.init(); - } +const prometheusSettingsSelector = '.js-prometheus-metrics-monitoring'; +const prometheusSettingsWrapper = document.querySelector(prometheusSettingsSelector); +if (prometheusSettingsWrapper) { + const customMetrics = new CustomMetrics(prometheusSettingsSelector); + customMetrics.init(); +} - PrometheusAlerts(); -}); +PrometheusAlerts(); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index 6fcaa3ab04b..261f7af7ef1 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -3,7 +3,6 @@ import { GlIcon, GlSprintf, GlLink, GlFormCheckbox, GlToggle } from '@gitlab/ui' import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin'; import { s__ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { visibilityOptions, visibilityLevelDescriptions, @@ -12,6 +11,7 @@ import { featureAccessLevel, featureAccessLevelNone, CVE_ID_REQUEST_BUTTON_I18N, + featureAccessLevelDescriptions, } from '../constants'; import { toggleHiddenClassBySelector } from '../external'; import projectFeatureSetting from './project_feature_setting.vue'; @@ -48,7 +48,7 @@ export default { GlFormCheckbox, GlToggle, }, - mixins: [settingsMixin, glFeatureFlagsMixin()], + mixins: [settingsMixin], props: { requestCveAvailable: { @@ -177,7 +177,7 @@ export default { requirementsAccessLevel: featureAccessLevel.EVERYONE, securityAndComplianceAccessLevel: featureAccessLevel.PROJECT_MEMBERS, operationsAccessLevel: featureAccessLevel.EVERYONE, - containerRegistryEnabled: true, + containerRegistryAccessLevel: featureAccessLevel.EVERYONE, lfsEnabled: true, requestAccessEnabled: true, highlightChangesClass: false, @@ -185,6 +185,8 @@ export default { cveIdRequestEnabled: true, featureAccessLevelEveryone, featureAccessLevelMembers, + featureAccessLevel, + featureAccessLevelDescriptions, }; return { ...defaults, ...this.currentSettings }; @@ -249,7 +251,10 @@ export default { }, showContainerRegistryPublicNote() { - return this.visibilityLevel === visibilityOptions.PUBLIC; + return ( + this.visibilityLevel === visibilityOptions.PUBLIC && + this.containerRegistryAccessLevel === featureAccessLevel.EVERYONE + ); }, repositoryHelpText() { @@ -311,6 +316,10 @@ export default { featureAccessLevel.PROJECT_MEMBERS, this.operationsAccessLevel, ); + this.containerRegistryAccessLevel = Math.min( + featureAccessLevel.PROJECT_MEMBERS, + this.containerRegistryAccessLevel, + ); if (this.pagesAccessLevel === featureAccessLevel.EVERYONE) { // When from Internal->Private narrow access for only members this.pagesAccessLevel = featureAccessLevel.PROJECT_MEMBERS; @@ -340,6 +349,8 @@ export default { this.requirementsAccessLevel = featureAccessLevel.EVERYONE; if (this.operationsAccessLevel === featureAccessLevel.PROJECT_MEMBERS) this.operationsAccessLevel = featureAccessLevel.EVERYONE; + if (this.containerRegistryAccessLevel === featureAccessLevel.PROJECT_MEMBERS) + this.containerRegistryAccessLevel = featureAccessLevel.EVERYONE; this.highlightChanges(); } @@ -513,31 +524,6 @@ export default { /> </project-setting-row> <project-setting-row - v-if="registryAvailable" - ref="container-registry-settings" - :help-path="registryHelpPath" - :label="$options.i18n.containerRegistryLabel" - :help-text=" - s__('ProjectSettings|Every project can have its own space to store its Docker images') - " - > - <div v-if="showContainerRegistryPublicNote" class="text-muted"> - {{ - s__( - 'ProjectSettings|Note: the container registry is always visible when a project is public', - ) - }} - </div> - <gl-toggle - v-model="containerRegistryEnabled" - class="gl-my-2" - :disabled="!repositoryEnabled" - :label="$options.i18n.containerRegistryLabel" - label-position="hidden" - name="project[container_registry_enabled]" - /> - </project-setting-row> - <project-setting-row v-if="lfsAvailable" ref="git-lfs-settings" :help-path="lfsHelpPath" @@ -590,18 +576,47 @@ export default { name="project[packages_enabled]" /> </project-setting-row> + <project-setting-row + ref="pipeline-settings" + :label="$options.i18n.ciCdLabel" + :help-text="s__('ProjectSettings|Build, test, and deploy your changes.')" + > + <project-feature-setting + v-model="buildsAccessLevel" + :label="$options.i18n.ciCdLabel" + :options="repoFeatureAccessLevelOptions" + :disabled-input="!repositoryEnabled" + name="project[project_feature_attributes][builds_access_level]" + /> + </project-setting-row> </div> <project-setting-row - ref="pipeline-settings" - :label="$options.i18n.ciCdLabel" - :help-text="s__('ProjectSettings|Build, test, and deploy your changes.')" + v-if="registryAvailable" + ref="container-registry-settings" + :help-path="registryHelpPath" + :label="$options.i18n.containerRegistryLabel" + :help-text=" + s__('ProjectSettings|Every project can have its own space to store its Docker images') + " > + <div v-if="showContainerRegistryPublicNote" class="text-muted"> + <gl-sprintf + :message=" + s__( + `ProjectSettings|Note: The container registry is always visible when a project is public and the container registry is set to '%{access_level_description}'`, + ) + " + > + <template #access_level_description>{{ + featureAccessLevelDescriptions[featureAccessLevel.EVERYONE] + }}</template> + </gl-sprintf> + </div> <project-feature-setting - v-model="buildsAccessLevel" - :label="$options.i18n.ciCdLabel" - :options="repoFeatureAccessLevelOptions" - :disabled-input="!repositoryEnabled" - name="project[project_feature_attributes][builds_access_level]" + v-model="containerRegistryAccessLevel" + :options="featureAccessLevelOptions" + :label="$options.i18n.containerRegistryLabel" + name="project[project_feature_attributes][container_registry_access_level]" /> </project-setting-row> <project-setting-row @@ -737,22 +752,5 @@ export default { }}</template> </gl-form-checkbox> </project-setting-row> - <project-setting-row - v-if="glFeatures.allowEditingCommitMessages" - ref="allow-editing-commit-messages" - class="gl-mb-4" - > - <input - :value="allowEditingCommitMessages" - type="hidden" - name="project[project_setting_attributes][allow_editing_commit_messages]" - /> - <gl-form-checkbox v-model="allowEditingCommitMessages"> - {{ s__('ProjectSettings|Allow editing commit messages') }} - <template #help>{{ - s__('ProjectSettings|Commit authors can edit commit messages on unprotected branches.') - }}</template> - </gl-form-checkbox> - </project-setting-row> </div> </template> diff --git a/app/assets/javascripts/pages/projects/shared/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js index e160fdacca6..fb1acd5311c 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/constants.js +++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js @@ -22,7 +22,7 @@ export const featureAccessLevel = { EVERYONE: 20, }; -const featureAccessLevelDescriptions = { +export const featureAccessLevelDescriptions = { [featureAccessLevel.NOT_ENABLED]: __('Enable feature to choose access level'), [featureAccessLevel.PROJECT_MEMBERS]: __('Only Project Members'), [featureAccessLevel.EVERYONE]: __('Everyone With Access'), diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index 26f8018a968..78b3f2f1b30 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -1,7 +1,7 @@ import initTree from 'ee_else_ce/repository'; import Activities from '~/activities'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -import BlobViewer from '~/blob/viewer/index'; +import { BlobViewer } from '~/blob/viewer/index'; import { initUploadForm } from '~/blob_edit/blob_bundle'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; diff --git a/app/assets/javascripts/pages/projects/static_site_editor/show/index.js b/app/assets/javascripts/pages/projects/static_site_editor/show/index.js index 8f808dae56c..d9d265e4e4a 100644 --- a/app/assets/javascripts/pages/projects/static_site_editor/show/index.js +++ b/app/assets/javascripts/pages/projects/static_site_editor/show/index.js @@ -1,5 +1,3 @@ import initStaticSiteEditor from '~/static_site_editor'; -window.addEventListener('DOMContentLoaded', () => { - initStaticSiteEditor(document.querySelector('#static-site-editor')); -}); +initStaticSiteEditor(document.querySelector('#static-site-editor')); diff --git a/app/assets/javascripts/pages/sessions/index.js b/app/assets/javascripts/pages/sessions/index.js index e93def5323f..8d8534ec556 100644 --- a/app/assets/javascripts/pages/sessions/index.js +++ b/app/assets/javascripts/pages/sessions/index.js @@ -1,3 +1,3 @@ import { mount2faAuthentication } from '~/authentication/mount_2fa'; -document.addEventListener('DOMContentLoaded', mount2faAuthentication); +mount2faAuthentication(); diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js index 465aed88c01..8c2fd624a83 100644 --- a/app/assets/javascripts/pages/sessions/new/index.js +++ b/app/assets/javascripts/pages/sessions/new/index.js @@ -7,18 +7,16 @@ import preserveUrlFragment from './preserve_url_fragment'; import SigninTabsMemoizer from './signin_tabs_memoizer'; import UsernameValidator from './username_validator'; -document.addEventListener('DOMContentLoaded', () => { - new UsernameValidator(); // eslint-disable-line no-new - new LengthValidator(); // eslint-disable-line no-new - new SigninTabsMemoizer(); // eslint-disable-line no-new - new NoEmojiValidator(); // eslint-disable-line no-new +new UsernameValidator(); // eslint-disable-line no-new +new LengthValidator(); // eslint-disable-line no-new +new SigninTabsMemoizer(); // eslint-disable-line no-new +new NoEmojiValidator(); // eslint-disable-line no-new - new OAuthRememberMe({ - container: $('.omniauth-container'), - }).bindEvents(); +new OAuthRememberMe({ + container: $('.omniauth-container'), +}).bindEvents(); - // Save the URL fragment from the current window location. This will be present if the user was - // redirected to sign-in after attempting to access a protected URL that included a fragment. - preserveUrlFragment(window.location.hash); - initVueAlerts(); -}); +// Save the URL fragment from the current window location. This will be present if the user was +// redirected to sign-in after attempting to access a protected URL that included a fragment. +preserveUrlFragment(window.location.hash); +initVueAlerts(); diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue index e883fecb170..a8ec731e105 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -6,7 +6,6 @@ import { GlButton, GlSprintf, GlAlert, - GlLoadingIcon, GlModal, GlModalDirective, } from '@gitlab/ui'; @@ -114,7 +113,6 @@ export default { GlButton, GlModal, MarkdownField, - GlLoadingIcon, ContentEditor: () => import( /* webpackChunkName: 'content_editor' */ '~/content_editor/components/content_editor.vue' @@ -134,14 +132,14 @@ export default { isContentEditorLoading: true, useContentEditor: false, commitMessage: '', - contentEditor: null, isDirty: false, contentEditorRenderFailed: false, + contentEditorEmpty: false, }; }, computed: { noContent() { - if (this.isContentEditorActive) return this.contentEditor?.empty; + if (this.isContentEditorActive) return this.contentEditorEmpty; return !this.content.trim(); }, csrfToken() { @@ -206,7 +204,7 @@ export default { window.removeEventListener('beforeunload', this.onPageUnload); }, methods: { - getContentHTML(content) { + renderMarkdown(content) { return axios .post(this.pageInfo.markdownPreviewPath, { text: content }) .then(({ data }) => data.body); @@ -233,6 +231,32 @@ export default { this.isDirty = true; }, + async loadInitialContent(contentEditor) { + this.contentEditor = contentEditor; + + try { + await this.contentEditor.setSerializedContent(this.content); + this.trackContentEditorLoaded(); + } catch (e) { + this.contentEditorRenderFailed = true; + } + }, + + async retryInitContentEditor() { + try { + this.contentEditorRenderFailed = false; + await this.contentEditor.setSerializedContent(this.content); + } catch (e) { + this.contentEditorRenderFailed = true; + } + }, + + handleContentEditorChange({ empty }) { + this.contentEditorEmpty = empty; + // TODO: Implement a precise mechanism to detect changes in the Content + this.isDirty = true; + }, + onPageUnload(event) { if (!this.isDirty) return undefined; @@ -253,36 +277,8 @@ export default { this.commitMessage = newCommitMessage; }, - async initContentEditor() { - this.isContentEditorLoading = true; + initContentEditor() { this.useContentEditor = true; - - const { createContentEditor } = await import( - /* webpackChunkName: 'content_editor' */ '~/content_editor/services/create_content_editor' - ); - this.contentEditor = - this.contentEditor || - createContentEditor({ - renderMarkdown: (markdown) => this.getContentHTML(markdown), - uploadsPath: this.pageInfo.uploadsPath, - tiptapOptions: { - onUpdate: () => this.handleContentChange(), - }, - }); - - try { - await this.contentEditor.setSerializedContent(this.content); - this.isContentEditorLoading = false; - - this.trackContentEditorLoaded(); - } catch (e) { - this.contentEditorRenderFailed = true; - } - }, - - retryInitContentEditor() { - this.contentEditorRenderFailed = false; - this.initContentEditor(); }, switchToOldEditor() { @@ -401,6 +397,7 @@ export default { v-if="showContentEditorAlert" class="gl-mb-6" variant="info" + data-qa-selector="try_new_editor_container" :primary-button-text="$options.i18n.contentEditor.useNewEditor.primaryLabel" :secondary-button-text="$options.i18n.contentEditor.useNewEditor.secondaryLabel" :dismiss-label="$options.i18n.contentEditor.useNewEditor.secondaryLabel" @@ -476,12 +473,12 @@ export default { > </gl-sprintf> </gl-alert> - <gl-loading-icon - v-if="isContentEditorLoading" - size="sm" - class="bordered-box gl-w-full gl-py-6" + <content-editor + :render-markdown="renderMarkdown" + :uploads-path="pageInfo.uploadsPath" + @initialized="loadInitialContent" + @change="handleContentEditorChange" /> - <content-editor v-else :content-editor="contentEditor" /> <input id="wiki_content" v-model.trim="content" type="hidden" name="wiki[content]" /> </div> diff --git a/app/assets/javascripts/performance/constants.js b/app/assets/javascripts/performance/constants.js index b9a9ef215af..28a4257c0c3 100644 --- a/app/assets/javascripts/performance/constants.js +++ b/app/assets/javascripts/performance/constants.js @@ -89,3 +89,14 @@ export const REPO_BLOB_LOAD_VIEWER_FINISH = 'blobviewer-load-viewer-finish'; // Measures export const REPO_BLOB_LOAD_VIEWER = 'Repository File Viewer: loading the viewer'; export const REPO_BLOB_SWITCH_VIEWER = 'Repository File Viewer: switching the viewer'; + +// +// DESIGN MANAGEMENT NAMESPACE +// + +// Marks +export const DESIGN_MARK_APP_START = 'design-app-start'; + +// Measures +export const DESIGN_MEASURE_BEFORE_APP = 'Design Management: Before the Vue app'; +export const DESIGN_MAIN_IMAGE_OUTPUT = 'Design Management: Single image preview'; diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index 214e1729bf8..670b0535ca3 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -40,7 +40,7 @@ export default { metric: 'active-record', title: 'pg', header: s__('PerformanceBar|SQL queries'), - keys: ['sql', 'cached', 'transaction', 'db_role'], + keys: ['sql', 'cached', 'transaction', 'db_role', 'db_config_name'], }, { metric: 'bullet', diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index cadcab16f16..8170a1f8443 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -7,7 +7,6 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-buy-pipeline-minutes-notification-callout', '.js-token-expiry-callout', '.js-registration-enabled-callout', - '.js-service-templates-deprecated-callout', '.js-new-user-signups-cap-reached', '.js-eoa-bronze-plan-banner', ]; diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue index ee6d4ff7c4d..9a6eed50fbe 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue +++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue @@ -212,6 +212,7 @@ export default { :text="currentBranch" icon="branch" data-qa-selector="branch_selector_button" + data-testid="branch-selector" > <gl-search-box-by-type :debounce="$options.inputDebounce" @input="setSearchTerm" /> <gl-dropdown-section-header> diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue index 6af3361e7e6..46f6f4a28c1 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import { GlButton, GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { truncateSha } from '~/lib/utils/text_utility'; import { s__ } from '~/locale'; @@ -19,12 +19,14 @@ export const i18n = { pipelineInfo: s__( `Pipeline|Pipeline %{idStart}#%{idEnd} %{statusStart}%{statusEnd} for %{commitStart}%{commitEnd}`, ), + viewBtn: s__('Pipeline|View pipeline'), }; export default { i18n, components: { CiIcon, + GlButton, GlIcon, GlLink, GlLoadingIcon, @@ -98,44 +100,63 @@ export default { </script> <template> - <div class="gl-white-space-nowrap gl-max-w-full"> + <div + class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-white-space-nowrap gl-max-w-full" + > <template v-if="showLoadingState"> - <gl-loading-icon class="gl-mr-auto gl-display-inline-block" size="sm" /> - <span data-testid="pipeline-loading-msg">{{ $options.i18n.fetchLoading }}</span> + <div> + <gl-loading-icon class="gl-mr-auto gl-display-inline-block" size="sm" /> + <span data-testid="pipeline-loading-msg">{{ $options.i18n.fetchLoading }}</span> + </div> </template> <template v-else-if="hasError"> - <gl-icon class="gl-mr-auto" name="warning-solid" /> - <span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span> + <div> + <gl-icon class="gl-mr-auto" name="warning-solid" /> + <span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span> + </div> </template> <template v-else> - <a :href="status.detailsPath" class="gl-mr-auto"> - <ci-icon :status="status" :size="16" /> - </a> - <span class="gl-font-weight-bold"> - <gl-sprintf :message="$options.i18n.pipelineInfo"> - <template #id="{ content }"> - <gl-link - :href="status.detailsPath" - class="pipeline-id gl-font-weight-normal pipeline-number" - target="_blank" - data-testid="pipeline-id" - > - {{ content }}{{ pipelineId }}</gl-link - > - </template> - <template #status>{{ status.text }}</template> - <template #commit> - <gl-link - :href="pipeline.commitPath" - class="commit-sha gl-font-weight-normal" - target="_blank" - data-testid="pipeline-commit" - > - {{ shortSha }} - </gl-link> - </template> - </gl-sprintf> - </span> + <div> + <a :href="status.detailsPath" class="gl-mr-auto"> + <ci-icon :status="status" :size="16" /> + </a> + <span class="gl-font-weight-bold"> + <gl-sprintf :message="$options.i18n.pipelineInfo"> + <template #id="{ content }"> + <gl-link + :href="status.detailsPath" + class="pipeline-id gl-font-weight-normal pipeline-number" + target="_blank" + data-testid="pipeline-id" + > + {{ content }}{{ pipelineId }}</gl-link + > + </template> + <template #status>{{ status.text }}</template> + <template #commit> + <gl-link + :href="pipeline.commitPath" + class="commit-sha gl-font-weight-normal" + target="_blank" + data-testid="pipeline-commit" + > + {{ shortSha }} + </gl-link> + </template> + </gl-sprintf> + </span> + </div> + <div> + <gl-button + target="_blank" + category="secondary" + variant="confirm" + :href="status.detailsPath" + data-testid="pipeline-view-btn" + > + {{ $options.i18n.viewBtn }} + </gl-button> + </div> </template> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index ea45b5e3ec7..015f0519c72 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -39,10 +39,10 @@ export default { required: false, default: false, }, - pipelineLayers: { - type: Array, + computedPipelineInfo: { + type: Object, required: false, - default: () => [], + default: () => ({}), }, type: { type: String, @@ -81,7 +81,10 @@ export default { layout() { return this.isStageView ? this.pipeline.stages - : generateColumnsFromLayersListMemoized(this.pipeline, this.pipelineLayers); + : generateColumnsFromLayersListMemoized( + this.pipeline, + this.computedPipelineInfo.pipelineLayers, + ); }, hasDownstreamPipelines() { return Boolean(this.pipeline?.downstream?.length > 0); @@ -92,6 +95,9 @@ export default { isStageView() { return this.viewType === STAGE_VIEW; }, + linksData() { + return this.computedPipelineInfo?.linksData ?? null; + }, metricsConfig() { return { path: this.configPaths.metricsPath, @@ -188,6 +194,7 @@ export default { :container-id="containerId" :container-measurements="measurements" :highlighted-job="hoveredJobName" + :links-data="linksData" :metrics-config="metricsConfig" :show-links="showJobLinks" :view-type="viewType" diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue deleted file mode 100644 index 39d0fa8a8ca..00000000000 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue +++ /dev/null @@ -1,269 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -import { escape, capitalize } from 'lodash'; -import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin'; -import { reportToSentry } from '../../utils'; -import { UPSTREAM, DOWNSTREAM, MAIN } from './constants'; -import LinkedPipelinesColumnLegacy from './linked_pipelines_column_legacy.vue'; -import StageColumnComponentLegacy from './stage_column_component_legacy.vue'; - -export default { - name: 'PipelineGraphLegacy', - components: { - GlLoadingIcon, - LinkedPipelinesColumnLegacy, - StageColumnComponentLegacy, - }, - mixins: [GraphBundleMixin], - props: { - isLoading: { - type: Boolean, - required: true, - }, - pipeline: { - type: Object, - required: true, - }, - isLinkedPipeline: { - type: Boolean, - required: false, - default: false, - }, - mediator: { - type: Object, - required: true, - }, - type: { - type: String, - required: false, - default: MAIN, - }, - }, - upstream: UPSTREAM, - downstream: DOWNSTREAM, - data() { - return { - downstreamMarginTop: null, - jobName: null, - pipelineExpanded: { - jobName: '', - expanded: false, - }, - }; - }, - computed: { - graph() { - return this.pipeline.details?.stages; - }, - hasUpstream() { - return ( - this.type !== this.$options.downstream && - this.upstreamPipelines && - this.pipeline.triggered_by !== null - ); - }, - upstreamPipelines() { - return this.pipeline.triggered_by; - }, - hasDownstream() { - return ( - this.type !== this.$options.upstream && - this.downstreamPipelines && - this.pipeline.triggered.length > 0 - ); - }, - downstreamPipelines() { - return this.pipeline.triggered; - }, - expandedUpstream() { - return ( - this.pipeline.triggered_by && - Array.isArray(this.pipeline.triggered_by) && - this.pipeline.triggered_by.find((el) => el.isExpanded) - ); - }, - expandedDownstream() { - return this.pipeline.triggered && this.pipeline.triggered.find((el) => el.isExpanded); - }, - pipelineTypeUpstream() { - return this.type !== this.$options.downstream && this.expandedUpstream; - }, - pipelineTypeDownstream() { - return this.type !== this.$options.upstream && this.expandedDownstream; - }, - pipelineProjectId() { - return this.pipeline.project.id; - }, - }, - errorCaptured(err, _vm, info) { - reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); - }, - methods: { - capitalizeStageName(name) { - const escapedName = escape(name); - return capitalize(escapedName); - }, - isFirstColumn(index) { - return index === 0; - }, - stageConnectorClass(index, stage) { - let className; - - // If it's the first stage column and only has one job - if (this.isFirstColumn(index) && stage.groups.length === 1) { - className = 'no-margin'; - } else if (index > 0) { - // If it is not the first column - className = 'left-margin'; - } - - return className; - }, - refreshPipelineGraph() { - this.$emit('refreshPipelineGraph'); - }, - /** - * CSS class is applied: - * - if pipeline graph contains only one stage column component - * - * @param {number} index - * @returns {boolean} - */ - shouldAddRightMargin(index) { - return !(index === this.graph.length - 1); - }, - handleClickedDownstream(pipeline, clickedIndex, downstreamNode) { - /** - * Calculates the margin top of the clicked downstream pipeline by - * subtracting the clicked downstream pipelines offsetTop by it's parent's - * offsetTop and then subtracting 15 - */ - this.downstreamMarginTop = this.calculateMarginTop(downstreamNode, 15); - - /** - * If the expanded trigger is defined and the id is different than the - * pipeline we clicked, then it means we clicked on a sibling downstream link - * and we want to reset the pipeline store. Triggering the reset without - * this condition would mean not allowing downstreams of downstreams to expand - */ - if (this.expandedDownstream?.id !== pipeline.id) { - this.$emit('onResetDownstream', this.pipeline, pipeline); - } - - this.$emit('onClickDownstreamPipeline', pipeline); - }, - calculateMarginTop(downstreamNode, pixelDiff) { - return `${downstreamNode.offsetTop - downstreamNode.offsetParent.offsetTop - pixelDiff}px`; - }, - hasOnlyOneJob(stage) { - return stage.groups.length === 1; - }, - hasUpstreamColumn(index) { - return index === 0 && this.hasUpstream; - }, - setJob(jobName) { - this.jobName = jobName; - }, - setPipelineExpanded(jobName, expanded) { - if (expanded) { - this.pipelineExpanded = { - jobName, - expanded, - }; - } else { - this.pipelineExpanded = { - expanded, - jobName: '', - }; - } - }, - }, -}; -</script> -<template> - <div class="build-content middle-block js-pipeline-graph"> - <div - class="pipeline-visualization pipeline-graph" - :class="{ 'pipeline-tab-content': !isLinkedPipeline }" - > - <div class="gl-w-full"> - <div class="container-fluid container-limited"> - <gl-loading-icon v-if="isLoading" class="m-auto" size="lg" /> - <pipeline-graph-legacy - v-if="pipelineTypeUpstream" - :type="$options.upstream" - class="d-inline-block upstream-pipeline" - :class="`js-upstream-pipeline-${expandedUpstream.id}`" - :is-loading="false" - :pipeline="expandedUpstream" - :is-linked-pipeline="true" - :mediator="mediator" - @onClickUpstreamPipeline="clickUpstreamPipeline" - @refreshPipelineGraph="requestRefreshPipelineGraph" - /> - - <linked-pipelines-column-legacy - v-if="hasUpstream" - :type="$options.upstream" - :linked-pipelines="upstreamPipelines" - :column-title="__('Upstream')" - :project-id="pipelineProjectId" - @linkedPipelineClick="$emit('onClickUpstreamPipeline', $event)" - /> - - <ul - v-if="!isLoading" - :class="{ - 'inline js-has-linked-pipelines': hasDownstream || hasUpstream, - }" - class="stage-column-list align-top" - > - <stage-column-component-legacy - v-for="(stage, index) in graph" - :key="stage.name" - :class="{ - 'has-upstream gl-ml-11': hasUpstreamColumn(index), - 'has-only-one-job': hasOnlyOneJob(stage), - 'gl-mr-26': shouldAddRightMargin(index), - }" - :title="capitalizeStageName(stage.name)" - :groups="stage.groups" - :stage-connector-class="stageConnectorClass(index, stage)" - :is-first-column="isFirstColumn(index)" - :has-upstream="hasUpstream" - :action="stage.status.action" - :job-hovered="jobName" - :pipeline-expanded="pipelineExpanded" - @refreshPipelineGraph="refreshPipelineGraph" - /> - </ul> - - <linked-pipelines-column-legacy - v-if="hasDownstream" - :type="$options.downstream" - :linked-pipelines="downstreamPipelines" - :column-title="__('Downstream')" - :project-id="pipelineProjectId" - @linkedPipelineClick="handleClickedDownstream" - @downstreamHovered="setJob" - @pipelineExpandToggle="setPipelineExpanded" - /> - - <pipeline-graph-legacy - v-if="pipelineTypeDownstream" - :type="$options.downstream" - class="d-inline-block" - :class="`js-downstream-pipeline-${expandedDownstream.id}`" - :is-loading="false" - :pipeline="expandedDownstream" - :is-linked-pipeline="true" - :style="{ 'margin-top': downstreamMarginTop }" - :mediator="mediator" - @onClickDownstreamPipeline="clickDownstreamPipeline" - @refreshPipelineGraph="requestRefreshPipelineGraph" - /> - </div> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue index a948a57c144..e995d400907 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -4,15 +4,15 @@ import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.qu import getUserCallouts from '~/graphql_shared/queries/get_user_callouts.query.graphql'; import { __ } from '~/locale'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants'; import DismissPipelineGraphCallout from '../../graphql/mutations/dismiss_pipeline_notification.graphql'; +import getPipelineQuery from '../../graphql/queries/get_pipeline_header_data.query.graphql'; import { reportToSentry, reportMessageToSentry } from '../../utils'; -import { listByLayers } from '../parsing_utils'; import { IID_FAILURE, LAYER_VIEW, STAGE_VIEW, VIEW_TYPE_KEY } from './constants'; import PipelineGraph from './graph_component.vue'; import GraphViewSelector from './graph_view_selector.vue'; import { + calculatePipelineLayersInfo, getQueryHeaders, serializeLoadErrors, toggleQueryPollingByVisibility, @@ -31,7 +31,6 @@ export default { LocalStorageSync, PipelineGraph, }, - mixins: [glFeatureFlagMixin()], inject: { graphqlResourceEtag: { default: '', @@ -50,9 +49,10 @@ export default { return { alertType: null, callouts: [], + computedPipelineInfo: null, currentViewType: STAGE_VIEW, + canRefetchHeaderPipeline: false, pipeline: null, - pipelineLayers: null, showAlert: false, showLinks: false, }; @@ -78,6 +78,26 @@ export default { ); }, }, + headerPipeline: { + query: getPipelineQuery, + // this query is already being called in header_component.vue, which shares the same cache as this component + // the skip here is to prevent sending double network requests on page load + skip() { + return !this.canRefetchHeaderPipeline; + }, + variables() { + return { + fullPath: this.pipelineProjectPath, + iid: this.pipelineIid, + }; + }, + update(data) { + return data.project?.pipeline || {}; + }, + error() { + this.reportFailure({ type: LOAD_FAILURE, skipSentry: true }); + }, + }, pipeline: { context() { return getQueryHeaders(this.graphqlResourceEtag); @@ -178,7 +198,7 @@ export default { return this.$apollo.queries.pipeline.loading && !this.pipeline; }, showGraphViewSelector() { - return Boolean(this.glFeatures.pipelineGraphLayersView && this.pipeline?.usesNeeds); + return this.pipeline?.usesNeeds; }, }, mounted() { @@ -192,12 +212,16 @@ export default { reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); }, methods: { - getPipelineLayers() { - if (this.currentViewType === LAYER_VIEW && !this.pipelineLayers) { - this.pipelineLayers = listByLayers(this.pipeline); + getPipelineInfo() { + if (this.currentViewType === LAYER_VIEW && !this.computedPipelineInfo) { + this.computedPipelineInfo = calculatePipelineLayersInfo( + this.pipeline, + this.$options.name, + this.metricsPath, + ); } - return this.pipelineLayers; + return this.computedPipelineInfo; }, handleTipDismissal() { try { @@ -217,6 +241,10 @@ export default { }, refreshPipelineGraph() { this.$apollo.queries.pipeline.refetch(); + + // this will update the status in header_component since they share the same cache + this.canRefetchHeaderPipeline = true; + this.$apollo.queries.headerPipeline.refetch(); }, /* eslint-disable @gitlab/require-i18n-strings */ reportFailure({ type, err = 'No error string passed.', skipSentry = false }) { @@ -262,7 +290,7 @@ export default { v-if="pipeline" :config-paths="configPaths" :pipeline="pipeline" - :pipeline-layers="getPipelineLayers()" + :computed-pipeline-info="getPipelineInfo()" :show-links="showLinks" :view-type="graphViewType" @error="reportFailure" diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue index 52ee40bd982..d251e0d8bd8 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -2,10 +2,10 @@ import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; import { LOAD_FAILURE } from '../../constants'; import { reportToSentry } from '../../utils'; -import { listByLayers } from '../parsing_utils'; import { ONE_COL_WIDTH, UPSTREAM, LAYER_VIEW, STAGE_VIEW } from './constants'; import LinkedPipeline from './linked_pipeline.vue'; import { + calculatePipelineLayersInfo, getQueryHeaders, serializeLoadErrors, toggleQueryPollingByVisibility, @@ -138,7 +138,11 @@ export default { }, getPipelineLayers(id) { if (this.viewType === LAYER_VIEW && !this.pipelineLayers[id]) { - this.pipelineLayers[id] = listByLayers(this.currentPipeline); + this.pipelineLayers[id] = calculatePipelineLayersInfo( + this.currentPipeline, + this.$options.name, + this.configPaths.metricsPath, + ); } return this.pipelineLayers[id]; @@ -223,7 +227,7 @@ export default { class="d-inline-block gl-mt-n2" :config-paths="configPaths" :pipeline="currentPipeline" - :pipeline-layers="getPipelineLayers(pipeline.id)" + :computed-pipeline-info="getPipelineLayers(pipeline.id)" :show-links="showLinks" :is-linked-pipeline="true" :view-type="graphViewType" diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue deleted file mode 100644 index 39baeb6e1c3..00000000000 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue +++ /dev/null @@ -1,91 +0,0 @@ -<script> -import { reportToSentry } from '../../utils'; -import { UPSTREAM } from './constants'; -import LinkedPipeline from './linked_pipeline.vue'; - -export default { - components: { - LinkedPipeline, - }, - props: { - columnTitle: { - type: String, - required: true, - }, - linkedPipelines: { - type: Array, - required: true, - }, - type: { - type: String, - required: true, - }, - projectId: { - type: Number, - required: true, - }, - }, - computed: { - columnClass() { - const positionValues = { - right: 'gl-ml-11', - left: 'gl-mr-7', - }; - return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`; - }, - graphPosition() { - return this.isUpstream ? 'left' : 'right'; - }, - isExpanded() { - return this.pipeline?.isExpanded || false; - }, - isUpstream() { - return this.type === UPSTREAM; - }, - }, - errorCaptured(err, _vm, info) { - reportToSentry('linked_pipelines_column_legacy', `error: ${err}, info: ${info}`); - }, - methods: { - onPipelineClick(downstreamNode, pipeline, index) { - this.$emit('linkedPipelineClick', pipeline, index, downstreamNode); - }, - onDownstreamHovered(jobName) { - this.$emit('downstreamHovered', jobName); - }, - onPipelineExpandToggle(jobName, expanded) { - // Highlighting only applies to downstream pipelines - if (this.isUpstream) { - return; - } - - this.$emit('pipelineExpandToggle', jobName, expanded); - }, - }, -}; -</script> - -<template> - <div :class="columnClass" class="stage-column linked-pipelines-column"> - <div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div> - <div v-if="isUpstream" class="cross-project-triangle"></div> - <ul> - <li v-for="(pipeline, index) in linkedPipelines" :key="pipeline.id"> - <linked-pipeline - :class="{ - active: pipeline.isExpanded, - 'left-connector': pipeline.isExpanded && graphPosition === 'left', - }" - :pipeline="pipeline" - :column-title="columnTitle" - :project-id="projectId" - :type="type" - :expanded="isExpanded" - @pipelineClicked="onPipelineClick($event, pipeline, index)" - @downstreamHovered="onDownstreamHovered" - @pipelineExpandToggle="onPipelineExpandToggle" - /> - </li> - </ul> - </div> -</template> diff --git a/app/assets/javascripts/pipelines/components/graph/perf_utils.js b/app/assets/javascripts/pipelines/components/graph/perf_utils.js new file mode 100644 index 00000000000..3737a209f5c --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/perf_utils.js @@ -0,0 +1,50 @@ +import { + PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START, + PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END, + PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, + PIPELINES_DETAIL_LINK_DURATION, + PIPELINES_DETAIL_LINKS_TOTAL, + PIPELINES_DETAIL_LINKS_JOB_RATIO, +} from '~/performance/constants'; + +import { performanceMarkAndMeasure } from '~/performance/utils'; +import { reportPerformance } from '../graph_shared/api'; + +export const beginPerfMeasure = () => { + performanceMarkAndMeasure({ mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START }); +}; + +export const finishPerfMeasureAndSend = (numLinks, numGroups, metricsPath) => { + performanceMarkAndMeasure({ + mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END, + measures: [ + { + name: PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, + start: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START, + }, + ], + }); + + window.requestAnimationFrame(() => { + const duration = window.performance.getEntriesByName( + PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, + )[0]?.duration; + + if (!duration) { + return; + } + + const data = { + histograms: [ + { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 }, + { name: PIPELINES_DETAIL_LINKS_TOTAL, value: numLinks }, + { + name: PIPELINES_DETAIL_LINKS_JOB_RATIO, + value: numLinks / numGroups, + }, + ], + }; + + reportPerformance(metricsPath, data); + }); +}; diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue deleted file mode 100644 index cbaf07c05cf..00000000000 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue +++ /dev/null @@ -1,112 +0,0 @@ -<script> -import { isEmpty, escape } from 'lodash'; -import stageColumnMixin from '../../mixins/stage_column_mixin'; -import { reportToSentry } from '../../utils'; -import ActionComponent from '../jobs_shared/action_component.vue'; -import JobGroupDropdown from './job_group_dropdown.vue'; -import JobItem from './job_item.vue'; - -export default { - components: { - JobItem, - JobGroupDropdown, - ActionComponent, - }, - mixins: [stageColumnMixin], - props: { - title: { - type: String, - required: true, - }, - groups: { - type: Array, - required: true, - }, - isFirstColumn: { - type: Boolean, - required: false, - default: false, - }, - stageConnectorClass: { - type: String, - required: false, - default: '', - }, - action: { - type: Object, - required: false, - default: () => ({}), - }, - jobHovered: { - type: String, - required: false, - default: '', - }, - pipelineExpanded: { - type: Object, - required: false, - default: () => ({}), - }, - }, - computed: { - hasAction() { - return !isEmpty(this.action); - }, - }, - errorCaptured(err, _vm, info) { - reportToSentry('stage_column_component_legacy', `error: ${err}, info: ${info}`); - }, - methods: { - groupId(group) { - return `ci-badge-${escape(group.name)}`; - }, - pipelineActionRequestComplete() { - this.$emit('refreshPipelineGraph'); - }, - }, -}; -</script> -<template> - <li :class="stageConnectorClass" class="stage-column"> - <div class="stage-name position-relative" data-testid="stage-column-title"> - {{ title }} - <action-component - v-if="hasAction" - :action-icon="action.icon" - :tooltip-text="action.title" - :link="action.path" - class="js-stage-action stage-action rounded" - @pipelineActionRequestComplete="pipelineActionRequestComplete" - /> - </div> - - <div class="builds-container"> - <ul> - <li - v-for="(group, index) in groups" - :id="groupId(group)" - :key="group.id" - :class="buildConnnectorClass(index)" - class="build" - > - <div class="curve"></div> - - <job-item - v-if="group.size === 1" - :job="group.jobs[0]" - :job-hovered="jobHovered" - :pipeline-expanded="pipelineExpanded" - css-class-job-name="build-content" - @pipelineActionRequestComplete="pipelineActionRequestComplete" - /> - - <job-group-dropdown - v-if="group.size > 1" - :group="group" - @pipelineActionRequestComplete="pipelineActionRequestComplete" - /> - </li> - </ul> - </div> - </li> -</template> diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js index 163b3898c28..3da792cb9df 100644 --- a/app/assets/javascripts/pipelines/components/graph/utils.js +++ b/app/assets/javascripts/pipelines/components/graph/utils.js @@ -1,7 +1,10 @@ import { isEmpty } from 'lodash'; import Visibility from 'visibilityjs'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { reportToSentry } from '../../utils'; +import { listByLayers } from '../parsing_utils'; import { unwrapStagesWithNeedsAndLookup } from '../unwrapping_utils'; +import { beginPerfMeasure, finishPerfMeasureAndSend } from './perf_utils'; const addMulti = (mainPipelineProjectPath, linkedPipeline) => { return { @@ -10,6 +13,28 @@ const addMulti = (mainPipelineProjectPath, linkedPipeline) => { }; }; +const calculatePipelineLayersInfo = (pipeline, componentName, metricsPath) => { + const shouldCollectMetrics = Boolean(metricsPath); + + if (shouldCollectMetrics) { + beginPerfMeasure(); + } + + let layers = null; + + try { + layers = listByLayers(pipeline); + + if (shouldCollectMetrics) { + finishPerfMeasureAndSend(layers.linksData.length, layers.numGroups, metricsPath); + } + } catch (err) { + reportToSentry(componentName, err); + } + + return layers; +}; + /* eslint-disable @gitlab/require-i18n-strings */ const getQueryHeaders = (etagResource) => { return { @@ -106,6 +131,7 @@ const unwrapPipelineData = (mainPipelineProjectPath, data) => { const validateConfigPaths = (value) => value.graphqlResourceEtag?.length > 0; export { + calculatePipelineLayersInfo, getQueryHeaders, serializeGqlErr, serializeLoadErrors, diff --git a/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js b/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js index 83f2466f0bf..d6d9ea94c13 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js +++ b/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js @@ -13,7 +13,7 @@ export const createUniqueLinkId = (stageName, jobName) => `${stageName}-${jobNam * @returns {Array} Links that contain all the information about them */ -export const generateLinksData = ({ links }, containerID, modifier = '') => { +export const generateLinksData = (links, containerID, modifier = '') => { const containerEl = document.getElementById(containerID); return links.map((link) => { diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue index 5c775df7b48..1189c2ebad8 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue +++ b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue @@ -17,8 +17,8 @@ export default { type: Object, required: true, }, - parsedData: { - type: Object, + linksData: { + type: Array, required: true, }, pipelineId: { @@ -95,7 +95,7 @@ export default { highlightedJobs(jobs) { this.$emit('highlightedJobsChange', jobs); }, - parsedData() { + linksData() { this.calculateLinkData(); }, viewType() { @@ -112,7 +112,7 @@ export default { reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); }, mounted() { - if (!isEmpty(this.parsedData)) { + if (!isEmpty(this.linksData)) { this.calculateLinkData(); } }, @@ -122,7 +122,7 @@ export default { }, calculateLinkData() { try { - this.links = generateLinksData(this.parsedData, this.containerId, `-${this.pipelineId}`); + this.links = generateLinksData(this.linksData, this.containerId, `-${this.pipelineId}`); } catch (err) { this.$emit('error', { type: DRAW_FAILURE, reportToSentry: false }); reportToSentry(this.$options.name, err); diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue index 81409752621..ef24694e494 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue +++ b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue @@ -1,20 +1,16 @@ <script> -import { isEmpty } from 'lodash'; -import { __ } from '~/locale'; -import { - PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START, - PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END, - PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, - PIPELINES_DETAIL_LINK_DURATION, - PIPELINES_DETAIL_LINKS_TOTAL, - PIPELINES_DETAIL_LINKS_JOB_RATIO, -} from '~/performance/constants'; -import { performanceMarkAndMeasure } from '~/performance/utils'; +import { memoize } from 'lodash'; import { reportToSentry } from '../../utils'; import { parseData } from '../parsing_utils'; -import { reportPerformance } from './api'; import LinksInner from './links_inner.vue'; +const parseForLinksBare = (pipeline) => { + const arrayOfJobs = pipeline.flatMap(({ groups }) => groups); + return parseData(arrayOfJobs).links; +}; + +const parseForLinks = memoize(parseForLinksBare); + export default { name: 'LinksLayer', components: { @@ -29,10 +25,10 @@ export default { type: Array, required: true, }, - metricsConfig: { - type: Object, + linksData: { + type: Array, required: false, - default: () => ({}), + default: () => [], }, showLinks: { type: Boolean, @@ -40,30 +36,16 @@ export default { default: true, }, }, - data() { - return { - alertDismissed: false, - parsedData: {}, - showLinksOverride: false, - }; - }, - i18n: { - showLinksAnyways: __('Show links anyways'), - tooManyJobs: __( - 'This graph has a large number of jobs and showing the links between them may have performance implications.', - ), - }, computed: { containerZero() { return !this.containerMeasurements.width || !this.containerMeasurements.height; }, - numGroups() { - return this.pipelineData.reduce((acc, { groups }) => { - return acc + Number(groups.length); - }, 0); - }, - shouldCollectMetrics() { - return this.metricsConfig.collectMetrics && this.metricsConfig.path; + getLinksData() { + if (this.linksData.length > 0) { + return this.linksData; + } + + return parseForLinks(this.pipelineData); }, showLinkedLayers() { return this.showLinks && !this.containerZero; @@ -72,77 +54,14 @@ export default { errorCaptured(err, _vm, info) { reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); }, - mounted() { - if (!isEmpty(this.pipelineData)) { - window.requestAnimationFrame(() => { - this.prepareLinkData(); - }); - } - }, - methods: { - beginPerfMeasure() { - if (this.shouldCollectMetrics) { - performanceMarkAndMeasure({ mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START }); - } - }, - finishPerfMeasureAndSend(numLinks) { - if (this.shouldCollectMetrics) { - performanceMarkAndMeasure({ - mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END, - measures: [ - { - name: PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, - start: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START, - }, - ], - }); - } - - window.requestAnimationFrame(() => { - const duration = window.performance.getEntriesByName( - PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, - )[0]?.duration; - - if (!duration) { - return; - } - - const data = { - histograms: [ - { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 }, - { name: PIPELINES_DETAIL_LINKS_TOTAL, value: numLinks }, - { - name: PIPELINES_DETAIL_LINKS_JOB_RATIO, - value: numLinks / this.numGroups, - }, - ], - }; - - reportPerformance(this.metricsConfig.path, data); - }); - }, - prepareLinkData() { - this.beginPerfMeasure(); - let numLinks; - try { - const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups); - this.parsedData = parseData(arrayOfJobs); - numLinks = this.parsedData.links.length; - } catch (err) { - reportToSentry(this.$options.name, err); - } - this.finishPerfMeasureAndSend(numLinks); - }, - }, }; </script> <template> <links-inner v-if="showLinkedLayers" :container-measurements="containerMeasurements" - :parsed-data="parsedData" + :links-data="getLinksData" :pipeline-data="pipelineData" - :total-groups="numGroups" v-bind="$attrs" v-on="$listeners" > diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index b7500ef00b0..5db2b604956 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -143,13 +143,6 @@ export default { return cancelable && userPermissions.updatePipeline; }, }, - watch: { - isFinished(finished) { - if (finished) { - this.$apollo.queries.pipeline.stopPolling(); - } - }, - }, methods: { reportFailure(errorType) { this.failureType = errorType; @@ -218,7 +211,7 @@ export default { }; </script> <template> - <div class="pipeline-header-container"> + <div class="js-pipeline-header-container"> <gl-alert v-if="hasError" :variant="failure.variant">{{ failure.text }}</gl-alert> <ci-header v-if="shouldRenderContent" diff --git a/app/assets/javascripts/pipelines/components/parsing_utils.js b/app/assets/javascripts/pipelines/components/parsing_utils.js index b36c9c0d049..7e7f0572faf 100644 --- a/app/assets/javascripts/pipelines/components/parsing_utils.js +++ b/app/assets/javascripts/pipelines/components/parsing_utils.js @@ -175,7 +175,7 @@ export const listByLayers = ({ stages }) => { const parsedData = parseData(arrayOfJobs); const dataWithLayers = createSankey()(parsedData); - return dataWithLayers.nodes.reduce((acc, { layer, name }) => { + const pipelineLayers = dataWithLayers.nodes.reduce((acc, { layer, name }) => { /* sort groups by layer */ if (!acc[layer]) { @@ -186,6 +186,12 @@ export const listByLayers = ({ stages }) => { return acc; }, []); + + return { + linksData: parsedData.links, + numGroups: arrayOfJobs.length, + pipelineLayers, + }; }; export const generateColumnsFromLayersListBare = ({ stages, stagesLookup }, pipelineLayers) => { diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue index 5e18f636b52..40ee071f1f5 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue @@ -16,6 +16,7 @@ export const i18n = { downloadArtifact: __('Download %{name} artifact'), artifactSectionHeader: __('Download artifacts'), artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'), + emptyArtifactsMessage: __('No artifacts found'), }; export default { @@ -99,6 +100,10 @@ export default { <gl-loading-icon v-if="isLoading" size="sm" /> + <gl-dropdown-item v-if="!artifacts.length" data-testid="artifacts-empty-message"> + {{ $options.i18n.emptyArtifactsMessage }} + </gl-dropdown-item> + <gl-dropdown-item v-for="(artifact, i) in artifacts" :key="i" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue index 85ee44f427d..b6c178d20b0 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue @@ -95,10 +95,10 @@ export default { :title="$options.i18n.cancelTitle" :loading="isCancelling" :disabled="isCancelling" - icon="close" + icon="cancel" variant="danger" category="primary" - class="js-pipelines-cancel-button" + class="js-pipelines-cancel-button gl-ml-1" @click="handleCancelClick" /> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue index fc8f31c5b7e..e2f30d5a8e6 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -29,6 +29,10 @@ export default { type: String, required: true, }, + pipelineKey: { + type: String, + required: true, + }, }, computed: { user() { @@ -60,7 +64,7 @@ export default { data-testid="pipeline-url-link" data-qa-selector="pipeline_url_link" > - #{{ pipeline.id }} + #{{ pipeline[pipelineKey] }} </gl-link> <div class="label-container"> <gl-badge diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index e3373178239..e7ff5449331 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -1,12 +1,17 @@ <script> -import { GlEmptyState, GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlEmptyState, GlIcon, GlLoadingIcon } from '@gitlab/ui'; import { isEqual } from 'lodash'; import createFlash from '~/flash'; import { getParameterByName } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; -import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../../constants'; +import { + ANY_TRIGGER_AUTHOR, + RAW_TEXT_WARNING, + FILTER_TAG_IDENTIFIER, + PipelineKeyOptions, +} from '../../constants'; import PipelinesMixin from '../../mixins/pipelines_mixin'; import PipelinesService from '../../services/pipelines_service'; import { validateParams } from '../../utils'; @@ -16,8 +21,11 @@ import PipelinesFilteredSearch from './pipelines_filtered_search.vue'; import PipelinesTableComponent from './pipelines_table.vue'; export default { + PipelineKeyOptions, components: { EmptyState, + GlDropdown, + GlDropdownItem, GlEmptyState, GlIcon, GlLoadingIcon, @@ -114,6 +122,7 @@ export default { page: getParameterByName('page') || '1', requestData: {}, isResetCacheButtonLoading: false, + selectedPipelineKeyOption: this.$options.PipelineKeyOptions[0], }; }, stateMap: { @@ -301,6 +310,9 @@ export default { this.updateContent(this.requestData); }, + changeVisibilityPipelineID(val) { + this.selectedPipelineKeyOption = val; + }, }, }; </script> @@ -330,12 +342,31 @@ export default { /> </div> - <pipelines-filtered-search - v-if="stateToRender !== $options.stateMap.emptyState" - :project-id="projectId" - :params="validatedParams" - @filterPipelines="filterPipelines" - /> + <div v-if="stateToRender !== $options.stateMap.emptyState" class="gl-display-flex"> + <div class="row-content-block gl-display-flex gl-flex-grow-1"> + <pipelines-filtered-search + class="gl-display-flex gl-flex-grow-1 gl-mr-4" + :project-id="projectId" + :params="validatedParams" + @filterPipelines="filterPipelines" + /> + <gl-dropdown + class="gl-display-flex" + :text="selectedPipelineKeyOption.text" + data-testid="pipeline-key-dropdown" + > + <gl-dropdown-item + v-for="(val, index) in $options.PipelineKeyOptions" + :key="index" + :is-checked="selectedPipelineKeyOption.key === val.key" + is-check-item + @click="changeVisibilityPipelineID(val)" + > + {{ val.text }} + </gl-dropdown-item> + </gl-dropdown> + </div> + </div> <div class="content-list pipelines"> <gl-loading-icon @@ -374,6 +405,7 @@ export default { :pipeline-schedule-url="pipelineScheduleUrl" :update-graph-dropdown="updateGraphDropdown" :view-type="viewType" + :pipeline-key-option="selectedPipelineKeyOption" /> </div> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue index de3f783ac84..0b70e74b8ff 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue @@ -4,6 +4,7 @@ import { map } from 'lodash'; import { s__ } from '~/locale'; import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import PipelineBranchNameToken from './tokens/pipeline_branch_name_token.vue'; +import PipelineSourceToken from './tokens/pipeline_source_token.vue'; import PipelineStatusToken from './tokens/pipeline_status_token.vue'; import PipelineTagNameToken from './tokens/pipeline_tag_name_token.vue'; import PipelineTriggerAuthorToken from './tokens/pipeline_trigger_author_token.vue'; @@ -13,6 +14,7 @@ export default { branchType: 'ref', tagType: 'tag', statusType: 'status', + sourceType: 'source', defaultTokensLength: 1, components: { GlFilteredSearch, @@ -37,7 +39,7 @@ export default { return this.value.map((i) => i.type); }, tokens() { - return [ + const tokens = [ { type: this.$options.userType, icon: 'user', @@ -76,6 +78,19 @@ export default { operators: OPERATOR_IS_ONLY, }, ]; + + if (gon.features.pipelineSourceFilter) { + tokens.push({ + type: this.$options.sourceType, + icon: 'trigger-source', + title: s__('Pipeline|Source'), + unique: true, + token: PipelineSourceToken, + operators: OPERATOR_IS_ONLY, + }); + } + + return tokens; }, parsedParams() { return map(this.params, (val, key) => ({ @@ -101,12 +116,10 @@ export default { </script> <template> - <div class="row-content-block"> - <gl-filtered-search - v-model="value" - :placeholder="__('Filter pipelines')" - :available-tokens="tokens" - @submit="onSubmit" - /> - </div> + <gl-filtered-search + v-model="value" + :placeholder="__('Filter pipelines')" + :available-tokens="tokens" + @submit="onSubmit" + /> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue index 47fc7023222..2475d958e3c 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -17,65 +17,10 @@ const DEFAULT_TH_CLASSES = 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1! gl-font-sm!'; export default { - fields: [ - { - key: 'status', - label: s__('Pipeline|Status'), - thClass: DEFAULT_TH_CLASSES, - columnClass: 'gl-w-10p', - tdClass: DEFAULT_TD_CLASS, - thAttr: { 'data-testid': 'status-th' }, - }, - { - key: 'pipeline', - label: s__('Pipeline|Pipeline'), - thClass: DEFAULT_TH_CLASSES, - tdClass: `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`, - columnClass: 'gl-w-10p', - thAttr: { 'data-testid': 'pipeline-th' }, - }, - { - key: 'triggerer', - label: s__('Pipeline|Triggerer'), - thClass: DEFAULT_TH_CLASSES, - tdClass: `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`, - columnClass: 'gl-w-10p', - thAttr: { 'data-testid': 'triggerer-th' }, - }, - { - key: 'commit', - label: s__('Pipeline|Commit'), - thClass: DEFAULT_TH_CLASSES, - tdClass: DEFAULT_TD_CLASS, - columnClass: 'gl-w-20p', - thAttr: { 'data-testid': 'commit-th' }, - }, - { - key: 'stages', - label: s__('Pipeline|Stages'), - thClass: DEFAULT_TH_CLASSES, - tdClass: DEFAULT_TD_CLASS, - columnClass: 'gl-w-15p', - thAttr: { 'data-testid': 'stages-th' }, - }, - { - key: 'timeago', - label: s__('Pipeline|Duration'), - thClass: DEFAULT_TH_CLASSES, - tdClass: DEFAULT_TD_CLASS, - columnClass: 'gl-w-15p', - thAttr: { 'data-testid': 'timeago-th' }, - }, - { - key: 'actions', - thClass: DEFAULT_TH_CLASSES, - tdClass: DEFAULT_TD_CLASS, - columnClass: 'gl-w-20p', - thAttr: { 'data-testid': 'actions-th' }, - }, - ], components: { GlTable, + LinkedPipelinesMiniList: () => + import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'), PipelinesCommit, PipelineMiniGraph, PipelineOperations, @@ -107,6 +52,10 @@ export default { type: String, required: true, }, + pipelineKeyOption: { + type: Object, + required: true, + }, }, data() { return { @@ -116,6 +65,68 @@ export default { cancelingPipeline: null, }; }, + computed: { + tableFields() { + const fields = [ + { + key: 'status', + label: s__('Pipeline|Status'), + thClass: DEFAULT_TH_CLASSES, + columnClass: 'gl-w-10p', + tdClass: DEFAULT_TD_CLASS, + thAttr: { 'data-testid': 'status-th' }, + }, + { + key: 'pipeline', + label: this.pipelineKeyOption.label, + thClass: DEFAULT_TH_CLASSES, + tdClass: `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`, + columnClass: 'gl-w-10p', + thAttr: { 'data-testid': 'pipeline-th' }, + }, + { + key: 'triggerer', + label: s__('Pipeline|Triggerer'), + thClass: DEFAULT_TH_CLASSES, + tdClass: `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`, + columnClass: 'gl-w-10p', + thAttr: { 'data-testid': 'triggerer-th' }, + }, + { + key: 'commit', + label: s__('Pipeline|Commit'), + thClass: DEFAULT_TH_CLASSES, + tdClass: DEFAULT_TD_CLASS, + columnClass: 'gl-w-20p', + thAttr: { 'data-testid': 'commit-th' }, + }, + { + key: 'stages', + label: s__('Pipeline|Stages'), + thClass: DEFAULT_TH_CLASSES, + tdClass: DEFAULT_TD_CLASS, + columnClass: 'gl-w-quarter', + thAttr: { 'data-testid': 'stages-th' }, + }, + { + key: 'timeago', + label: s__('Pipeline|Duration'), + thClass: DEFAULT_TH_CLASSES, + tdClass: DEFAULT_TD_CLASS, + columnClass: 'gl-w-15p', + thAttr: { 'data-testid': 'timeago-th' }, + }, + { + key: 'actions', + thClass: DEFAULT_TH_CLASSES, + tdClass: DEFAULT_TD_CLASS, + columnClass: 'gl-w-15p', + thAttr: { 'data-testid': 'actions-th' }, + }, + ]; + return fields; + }, + }, watch: { pipelines() { this.cancelingPipeline = null; @@ -146,7 +157,7 @@ export default { <template> <div class="ci-table"> <gl-table - :fields="$options.fields" + :fields="tableFields" :items="pipelines" tbody-tr-class="commit" :tbody-tr-attr="{ 'data-testid': 'pipeline-table-row' }" @@ -167,7 +178,11 @@ export default { </template> <template #cell(pipeline)="{ item }"> - <pipeline-url :pipeline="item" :pipeline-schedule-url="pipelineScheduleUrl" /> + <pipeline-url + :pipeline="item" + :pipeline-schedule-url="pipelineScheduleUrl" + :pipeline-key="pipelineKeyOption.key" + /> </template> <template #cell(triggerer)="{ item }"> @@ -182,12 +197,23 @@ export default { <div class="stage-cell"> <!-- This empty div should be removed, see https://gitlab.com/gitlab-org/gitlab/-/issues/323488 --> <div></div> + <linked-pipelines-mini-list + v-if="item.triggered_by" + :triggered-by="[item.triggered_by]" + data-testid="mini-graph-upstream" + /> <pipeline-mini-graph v-if="item.details && item.details.stages && item.details.stages.length > 0" + class="gl-display-inline" :stages="item.details.stages" :update-dropdown="updateGraphDropdown" @pipelineActionRequestComplete="onPipelineActionRequestComplete" /> + <linked-pipelines-mini-list + v-if="item.triggered.length" + :triggered="item.triggered" + data-testid="mini-graph-downstream" + /> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_source_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_source_token.vue new file mode 100644 index 00000000000..71efa8b2ab4 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_source_token.vue @@ -0,0 +1,106 @@ +<script> +import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + computed: { + sources() { + return [ + { + text: s__('Pipeline|Source|Push'), + value: 'push', + }, + { + text: s__('Pipeline|Source|Web'), + value: 'web', + }, + { + text: s__('Pipeline|Source|Trigger'), + value: 'trigger', + }, + { + text: s__('Pipeline|Source|Schedule'), + value: 'schedule', + }, + { + text: s__('Pipeline|Source|API'), + value: 'api', + }, + { + text: s__('Pipeline|Source|External'), + value: 'external', + }, + { + text: s__('Pipeline|Source|Pipeline'), + value: 'pipeline', + }, + { + text: s__('Pipeline|Source|Chat'), + value: 'chat', + }, + { + text: s__('Pipeline|Source|Web IDE'), + value: 'webide', + }, + { + text: s__('Pipeline|Source|Merge Request'), + value: 'merge_request_event', + }, + { + text: s__('Pipeline|Source|External Pull Request'), + value: 'external_pull_request_event', + }, + { + text: s__('Pipeline|Source|Parent Pipeline'), + value: 'parent_pipeline', + }, + { + text: s__('Pipeline|Source|On-Demand DAST Scan'), + value: 'ondemand_dast_scan', + }, + { + text: s__('Pipeline|Source|On-Demand DAST Validation'), + value: 'ondemand_dast_validation', + }, + ]; + }, + findActiveSource() { + return this.sources.find((source) => source.value === this.value.data); + }, + }, +}; +</script> + +<template> + <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners"> + <template #view> + <div class="gl-display-flex gl-align-items-center"> + <span>{{ findActiveSource.text }}</span> + </div> + </template> + + <template #suggestions> + <gl-filtered-search-suggestion + v-for="source in sources" + :key="source.value" + :value="source.value" + > + {{ source.text }} + </gl-filtered-search-suggestion> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index 21b114825a6..5678b613ec6 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -4,7 +4,7 @@ export const CANCEL_REQUEST = 'CANCEL_REQUEST'; export const LAYOUT_CHANGE_DELAY = 300; export const FILTER_PIPELINES_SEARCH_DELAY = 200; export const ANY_TRIGGER_AUTHOR = 'Any'; -export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref', 'status']; +export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref', 'status', 'source']; export const FILTER_TAG_IDENTIFIER = 'tag'; export const SCHEDULE_ORIGIN = 'schedule'; @@ -35,3 +35,17 @@ export const POST_FAILURE = 'post_failure'; export const UNSUPPORTED_DATA = 'unsupported_data'; export const CHILD_VIEW = 'child'; + +// Constants for the ID and IID selection dropdown +export const PipelineKeyOptions = [ + { + text: __('Show Pipeline ID'), + label: __('Pipeline ID'), + key: 'id', + }, + { + text: __('Show Pipeline IID'), + label: __('Pipeline IID'), + key: 'iid', + }, +]; diff --git a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js deleted file mode 100644 index 5c34f4e4f7e..00000000000 --- a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js +++ /dev/null @@ -1,65 +0,0 @@ -import createFlash from '~/flash'; -import { __ } from '~/locale'; - -export default { - methods: { - getExpandedPipelines(pipeline) { - this.mediator.service - .getPipeline(this.mediator.getExpandedParameters()) - .then((response) => { - this.mediator.store.toggleLoading(pipeline); - this.mediator.store.storePipeline(response.data); - this.mediator.poll.enable({ data: this.mediator.getExpandedParameters() }); - }) - .catch(() => { - this.mediator.store.toggleLoading(pipeline); - createFlash({ - message: __('An error occurred while fetching the pipeline.'), - }); - }); - }, - /** - * Called when a linked pipeline is clicked. - * - * If the pipeline is collapsed we will start polling it & we will reset the other pipelines. - * If the pipeline is expanded we will close it. - * - * @param {String} method Method to fetch the pipeline - * @param {String} storeKey Store property that will be updates - * @param {String} resetStoreKey Store key for the visible pipeline that will need to be reset - * @param {Object} pipeline The clicked pipeline - */ - clickPipeline(pipeline, openMethod, closeMethod) { - if (!pipeline.isExpanded) { - this.mediator.store[openMethod](pipeline); - this.mediator.store.toggleLoading(pipeline); - this.mediator.poll.stop(); - - this.getExpandedPipelines(pipeline); - } else { - this.mediator.store[closeMethod](pipeline); - this.mediator.poll.stop(); - - this.mediator.poll.enable({ data: this.mediator.getExpandedParameters() }); - } - }, - resetDownstreamPipelines(parentPipeline, pipeline) { - this.mediator.store.resetTriggeredPipelines(parentPipeline, pipeline); - }, - clickUpstreamPipeline(pipeline) { - this.clickPipeline(pipeline, 'openPipeline', 'closePipeline'); - }, - clickDownstreamPipeline(pipeline) { - this.clickPipeline(pipeline, 'openPipeline', 'closePipeline'); - }, - requestRefreshPipelineGraph() { - // When an action is clicked - // (whether in the dropdown or in the main nodes, we refresh the big graph) - this.mediator.refreshPipeline().catch(() => - createFlash({ - message: __('An error occurred while making the request.'), - }), - ); - }, - }, -}; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index e8d5ed175ba..c6e767d5424 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -3,15 +3,12 @@ import createFlash from '~/flash'; import { parseBoolean } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import Translate from '~/vue_shared/translate'; -import PipelineGraphLegacy from './components/graph/graph_component_legacy.vue'; import TestReports from './components/test_reports/test_reports.vue'; -import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import createDagApp from './pipeline_details_dag'; import { createPipelinesDetailApp } from './pipeline_details_graph'; import { createPipelineHeaderApp } from './pipeline_details_header'; import { apolloProvider } from './pipeline_shared_client'; import createTestReportsStore from './stores/test_reports'; -import { reportToSentry } from './utils'; Vue.use(Translate); @@ -22,44 +19,6 @@ const SELECTORS = { PIPELINE_TESTS: '#js-pipeline-tests-detail', }; -const createLegacyPipelinesDetailApp = (mediator) => { - if (!document.querySelector(SELECTORS.PIPELINE_GRAPH)) { - return; - } - // eslint-disable-next-line no-new - new Vue({ - el: SELECTORS.PIPELINE_GRAPH, - components: { - PipelineGraphLegacy, - }, - mixins: [GraphBundleMixin], - data() { - return { - mediator, - }; - }, - errorCaptured(err, _vm, info) { - reportToSentry('pipeline_details_bundle_legacy_details', `error: ${err}, info: ${info}`); - }, - render(createElement) { - return createElement('pipeline-graph-legacy', { - props: { - isLoading: this.mediator.state.isLoading, - pipeline: this.mediator.store.state.pipeline, - mediator: this.mediator, - }, - on: { - refreshPipelineGraph: this.requestRefreshPipelineGraph, - onResetDownstream: (parentPipeline, pipeline) => - this.resetDownstreamPipelines(parentPipeline, pipeline), - onClickUpstreamPipeline: (pipeline) => this.clickUpstreamPipeline(pipeline), - onClickDownstreamPipeline: (pipeline) => this.clickDownstreamPipeline(pipeline), - }, - }); - }, - }); -}; - const createTestDetails = () => { const el = document.querySelector(SELECTORS.PIPELINE_TESTS); const { blobPath, emptyStateImagePath, hasTestReport, summaryEndpoint, suiteEndpoint } = @@ -88,9 +47,6 @@ const createTestDetails = () => { }; export default async function initPipelineDetailsBundle() { - const canShowNewPipelineDetails = - gon.features.graphqlPipelineDetails || gon.features.graphqlPipelineDetailsUsers; - const { dataset } = document.querySelector(SELECTORS.PIPELINE_DETAILS); try { @@ -101,22 +57,12 @@ export default async function initPipelineDetailsBundle() { }); } - if (canShowNewPipelineDetails) { - try { - createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset); - } catch { - createFlash({ - message: __('An error occurred while loading the pipeline.'), - }); - } - } else { - const { default: PipelinesMediator } = await import( - /* webpackChunkName: 'PipelinesMediator' */ './pipeline_details_mediator' - ); - const mediator = new PipelinesMediator({ endpoint: dataset.endpoint }); - mediator.fetchPipeline(); - - createLegacyPipelinesDetailApp(mediator); + try { + createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset); + } catch { + createFlash({ + message: __('An error occurred while loading the pipeline.'), + }); } createDagApp(apolloProvider); diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js deleted file mode 100644 index 72c4fedc64c..00000000000 --- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js +++ /dev/null @@ -1,81 +0,0 @@ -import Visibility from 'visibilityjs'; -import createFlash from '~/flash'; -import Poll from '../lib/utils/poll'; -import { __ } from '../locale'; -import PipelineService from './services/pipeline_service'; -import PipelineStore from './stores/pipeline_store'; - -export default class pipelinesMediator { - constructor(options = {}) { - this.options = options; - this.store = new PipelineStore(); - this.service = new PipelineService(options.endpoint); - - this.state = {}; - this.state.isLoading = false; - } - - fetchPipeline() { - this.poll = new Poll({ - resource: this.service, - method: 'getPipeline', - data: this.store.state.expandedPipelines ? this.getExpandedParameters() : undefined, - successCallback: this.successCallback.bind(this), - errorCallback: this.errorCallback.bind(this), - }); - - if (!Visibility.hidden()) { - this.state.isLoading = true; - this.poll.makeRequest(); - } else { - this.refreshPipeline(); - } - - Visibility.change(() => { - if (!Visibility.hidden()) { - this.poll.restart(); - } else { - this.stopPipelinePoll(); - } - }); - } - - successCallback(response) { - this.state.isLoading = false; - this.store.storePipeline(response.data); - } - - errorCallback() { - this.state.isLoading = false; - createFlash({ - message: __('An error occurred while fetching the pipeline.'), - }); - } - - refreshPipeline() { - this.stopPipelinePoll(); - - return this.service - .getPipeline() - .then((response) => this.successCallback(response)) - .catch(() => this.errorCallback()) - .finally(() => - this.poll.restart( - this.store.state.expandedPipelines ? this.getExpandedParameters() : undefined, - ), - ); - } - - stopPipelinePoll() { - this.poll.stop(); - } - - /** - * Backend expects paramets in the following format: `expanded[]=id&expanded[]=id` - */ - getExpandedParameters() { - return { - expanded: this.store.state.expandedPipelines, - }; - } -} diff --git a/app/assets/javascripts/pipelines/services/pipeline_service.js b/app/assets/javascripts/pipelines/services/pipeline_service.js deleted file mode 100644 index ba2830ec596..00000000000 --- a/app/assets/javascripts/pipelines/services/pipeline_service.js +++ /dev/null @@ -1,21 +0,0 @@ -import axios from '../../lib/utils/axios_utils'; - -export default class PipelineService { - constructor(endpoint) { - this.pipeline = endpoint; - } - - getPipeline(params) { - return axios.get(this.pipeline, { params }); - } - - // eslint-disable-next-line class-methods-use-this - deleteAction(endpoint) { - return axios.delete(`${endpoint}.json`); - } - - // eslint-disable-next-line class-methods-use-this - postAction(endpoint) { - return axios.post(`${endpoint}.json`); - } -} diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js deleted file mode 100644 index 1f804a107a8..00000000000 --- a/app/assets/javascripts/pipelines/stores/pipeline_store.js +++ /dev/null @@ -1,206 +0,0 @@ -import Vue from 'vue'; - -export default class PipelineStore { - constructor() { - this.state = {}; - this.state.pipeline = {}; - this.state.expandedPipelines = []; - } - /** - * For the triggered pipelines adds the `isExpanded` key - * - * For the triggered_by pipeline adds the `isExpanded` key - * and saves it as an array - * - * @param {Object} pipeline - */ - storePipeline(pipeline = {}) { - const pipelineCopy = { ...pipeline }; - - if (pipelineCopy.triggered_by) { - pipelineCopy.triggered_by = [pipelineCopy.triggered_by]; - - const oldTriggeredBy = - this.state.pipeline && - this.state.pipeline.triggered_by && - this.state.pipeline.triggered_by[0]; - - this.parseTriggeredByPipelines(oldTriggeredBy, pipelineCopy.triggered_by[0]); - } - - if (pipelineCopy.triggered && pipelineCopy.triggered.length) { - pipelineCopy.triggered.forEach((el) => { - const oldPipeline = - this.state.pipeline && - this.state.pipeline.triggered && - this.state.pipeline.triggered.find((element) => element.id === el.id); - - this.parseTriggeredPipelines(oldPipeline, el); - }); - } - - this.state.pipeline = pipelineCopy; - } - - /** - * Recursiverly parses the triggered by pipelines. - * - * Sets triggered_by as an array, there is always only 1 triggered_by pipeline. - * Adds key `isExpanding` - * Keeps old isExpading value due to polling - * - * @param {Array} parentPipeline - * @param {Object} pipeline - */ - parseTriggeredByPipelines(oldPipeline = {}, newPipeline) { - // keep old value in case it's opened because we're polling - Vue.set(newPipeline, 'isExpanded', oldPipeline.isExpanded || false); - // add isLoading property - Vue.set(newPipeline, 'isLoading', false); - - // Because there can only ever be one `triggered_by` for any given pipeline, - // the API returns an object for the value instead of an Array. However, - // it's easier to deal with an array in the FE so we convert it. - if (newPipeline.triggered_by) { - if (!Array.isArray(newPipeline.triggered_by)) { - Object.assign(newPipeline, { triggered_by: [newPipeline.triggered_by] }); - } - - if (newPipeline.triggered_by?.length > 0) { - newPipeline.triggered_by.forEach((el) => { - const oldTriggeredBy = oldPipeline.triggered_by?.find((element) => element.id === el.id); - this.parseTriggeredPipelines(oldTriggeredBy, el); - }); - } - } - } - - /** - * Recursively parses the triggered pipelines - * @param {Array} parentPipeline - * @param {Object} pipeline - */ - parseTriggeredPipelines(oldPipeline = {}, newPipeline) { - // keep old value in case it's opened because we're polling - Vue.set(newPipeline, 'isExpanded', oldPipeline.isExpanded || false); - - // add isLoading property - Vue.set(newPipeline, 'isLoading', false); - - if (newPipeline.triggered && newPipeline.triggered.length > 0) { - newPipeline.triggered.forEach((el) => { - const oldTriggered = - oldPipeline.triggered && oldPipeline.triggered.find((element) => element.id === el.id); - this.parseTriggeredPipelines(oldTriggered, el); - }); - } - } - - /** - * Recursively resets all triggered by pipelines - * - * @param {Object} pipeline - */ - resetTriggeredByPipeline(parentPipeline, pipeline) { - parentPipeline.triggered_by.forEach((el) => this.closePipeline(el)); - - if (pipeline.triggered_by && pipeline.triggered_by) { - this.resetTriggeredByPipeline(pipeline, pipeline.triggered_by); - } - } - - /** - * Opens the clicked pipeline and closes all other ones. - * @param {Object} pipeline - */ - openTriggeredByPipeline(parentPipeline, pipeline) { - // first we need to reset all triggeredBy pipelines - this.resetTriggeredByPipeline(parentPipeline, pipeline); - - this.openPipeline(pipeline); - } - - /** - * On click, will close the given pipeline and all nested triggered by pipelines - * - * @param {Object} pipeline - */ - closeTriggeredByPipeline(pipeline) { - this.closePipeline(pipeline); - - if (pipeline.triggered_by && pipeline.triggered_by.length) { - pipeline.triggered_by.forEach((triggeredBy) => this.closeTriggeredByPipeline(triggeredBy)); - } - } - - /** - * Recursively closes all triggered pipelines for the given one. - * - * @param {Object} pipeline - */ - resetTriggeredPipelines(parentPipeline, pipeline) { - parentPipeline.triggered.forEach((el) => this.closePipeline(el)); - - if (pipeline.triggered && pipeline.triggered.length) { - pipeline.triggered.forEach((el) => this.resetTriggeredPipelines(pipeline, el)); - } - } - - /** - * Opens the clicked triggered pipeline and closes all other ones. - * - * @param {Object} pipeline - */ - openTriggeredPipeline(parentPipeline, pipeline) { - this.resetTriggeredPipelines(parentPipeline, pipeline); - - this.openPipeline(pipeline); - } - - /** - * On click, will close the given pipeline and all the nested triggered ones - * @param {Object} pipeline - */ - closeTriggeredPipeline(pipeline) { - this.closePipeline(pipeline); - - if (pipeline.triggered && pipeline.triggered.length) { - pipeline.triggered.forEach((triggered) => this.closeTriggeredPipeline(triggered)); - } - } - - /** - * Utility function, Closes the given pipeline - * @param {Object} pipeline - */ - closePipeline(pipeline) { - Vue.set(pipeline, 'isExpanded', false); - // remove the pipeline from the parameters - this.removeExpandedPipelineToRequestData(pipeline.id); - } - - /** - * Utility function, Opens the given pipeline - * @param {Object} pipeline - */ - openPipeline(pipeline) { - Vue.set(pipeline, 'isExpanded', true); - // add the pipeline to the parameters - this.addExpandedPipelineToRequestData(pipeline.id); - } - // eslint-disable-next-line class-methods-use-this - toggleLoading(pipeline) { - Vue.set(pipeline, 'isLoading', !pipeline.isLoading); - } - - addExpandedPipelineToRequestData(id) { - this.state.expandedPipelines.push(id); - } - - removeExpandedPipelineToRequestData(id) { - this.state.expandedPipelines.splice( - this.state.expandedPipelines.findIndex((el) => el === id), - 1, - ); - } -} diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue index a0129dd536b..757a66ef148 100644 --- a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue +++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue @@ -127,20 +127,18 @@ export default { :config="$options.integrationViewConfigs[view.name]" /> </div> - <div class="col-lg-4 profile-settings-sidebar"></div> - <div class="col-lg-8"> - <div class="form-group"> - <gl-button - category="primary" - variant="confirm" - name="commit" - type="submit" - :disabled="!isSubmitEnabled" - :value="$options.i18n.saveChanges" - > - {{ $options.i18n.saveChanges }} - </gl-button> - </div> + <div class="col-sm-12"> + <hr /> + <gl-button + category="primary" + variant="confirm" + name="commit" + type="submit" + :disabled="!isSubmitEnabled" + :value="$options.i18n.saveChanges" + > + {{ $options.i18n.saveChanges }} + </gl-button> </div> </div> </template> diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue new file mode 100644 index 00000000000..a4a1cb5584d --- /dev/null +++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue @@ -0,0 +1,94 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; +import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; +import getLinkedPipelinesQuery from '../graphql/queries/get_linked_pipelines.query.graphql'; + +export default { + i18n: { + linkedPipelinesFetchError: __('There was a problem fetching linked pipelines.'), + }, + components: { + GlLoadingIcon, + PipelineMiniGraph, + LinkedPipelinesMiniList: () => + import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'), + }, + inject: { + fullPath: { + default: '', + }, + iid: { + default: '', + }, + }, + props: { + stages: { + type: Array, + required: true, + }, + }, + apollo: { + pipeline: { + query: getLinkedPipelinesQuery, + variables() { + return { + fullPath: this.fullPath, + iid: this.iid, + }; + }, + skip() { + return !this.fullPath || !this.iid; + }, + update({ project }) { + return project?.pipeline; + }, + error() { + createFlash({ message: this.$options.i18n.linkedPipelinesFetchError }); + }, + }, + }, + data() { + return { + pipeline: null, + }; + }, + computed: { + hasDownstream() { + return this.pipeline?.downstream?.nodes.length > 0; + }, + downstreamPipelines() { + return this.pipeline?.downstream?.nodes; + }, + upstreamPipeline() { + return this.pipeline?.upstream; + }, + }, +}; +</script> + +<template> + <div> + <gl-loading-icon v-if="$apollo.queries.pipeline.loading" /> + <div v-else> + <linked-pipelines-mini-list + v-if="upstreamPipeline" + :triggered-by="[upstreamPipeline]" + data-testid="commit-box-mini-graph-upstream" + /> + + <pipeline-mini-graph + :stages="stages" + class="gl-display-inline" + data-testid="commit-box-mini-graph" + /> + + <linked-pipelines-mini-list + v-if="hasDownstream" + :triggered="downstreamPipelines" + data-testid="commit-box-mini-graph-downstream" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql new file mode 100644 index 00000000000..f7e930bb3f2 --- /dev/null +++ b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql @@ -0,0 +1,32 @@ +query getLinkedPipelines($fullPath: ID!, $iid: ID!) { + project(fullPath: $fullPath) { + pipeline(iid: $iid) { + downstream { + nodes { + id + path + project { + name + } + detailedStatus { + group + icon + label + } + } + } + upstream { + id + path + project { + name + } + detailedStatus { + group + icon + label + } + } + } + } +} diff --git a/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js index 9173f5c771f..1d4ec4c110b 100644 --- a/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js +++ b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js @@ -1,24 +1,41 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); export const initCommitPipelineMiniGraph = async (selector = '.js-commit-pipeline-mini-graph') => { const el = document.querySelector(selector); + if (!el) { return; } + const { stages, fullPath, iid } = el.dataset; + // Some commits have no pipeline, code splitting to load the pipeline optionally - const { stages } = el.dataset; - const { default: PipelineMiniGraph } = await import( - /* webpackChunkName: 'pipelineMiniGraph' */ '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue' + const { default: CommitBoxPipelineMiniGraph } = await import( + /* webpackChunkName: 'commitBoxPipelineMiniGraph' */ './components/commit_box_pipeline_mini_graph.vue' ); // eslint-disable-next-line no-new new Vue({ el, + apolloProvider, + provide: { + fullPath, + iid, + dataMethod: 'graphql', + }, render(createElement) { - return createElement(PipelineMiniGraph, { + return createElement(CommitBoxPipelineMiniGraph, { props: { - stages: JSON.parse(stages), + // if stages do not exist for some reason, protect JSON.parse from erroring out + stages: stages ? JSON.parse(stages) : [], }, }); }, diff --git a/app/assets/javascripts/projects/compare/components/app.vue b/app/assets/javascripts/projects/compare/components/app.vue index f7cfc82db11..f2c1c843878 100644 --- a/app/assets/javascripts/projects/compare/components/app.vue +++ b/app/assets/javascripts/projects/compare/components/app.vue @@ -122,7 +122,7 @@ export default { /> </div> <div class="gl-mt-4"> - <gl-button category="primary" variant="success" @click="onSubmit"> + <gl-button category="primary" variant="confirm" @click="onSubmit"> {{ s__('CompareRevisions|Compare') }} </gl-button> <gl-button data-testid="swapRevisionsButton" class="btn btn-default" @click="onSwapRevision"> diff --git a/app/assets/javascripts/projects/compare/components/app_legacy.vue b/app/assets/javascripts/projects/compare/components/app_legacy.vue deleted file mode 100644 index d3f09f7d69f..00000000000 --- a/app/assets/javascripts/projects/compare/components/app_legacy.vue +++ /dev/null @@ -1,112 +0,0 @@ -<script> -import { GlButton } from '@gitlab/ui'; -import csrf from '~/lib/utils/csrf'; -import RevisionDropdown from './revision_dropdown_legacy.vue'; - -export default { - csrf, - components: { - RevisionDropdown, - GlButton, - }, - props: { - projectCompareIndexPath: { - type: String, - required: true, - }, - refsProjectPath: { - type: String, - required: true, - }, - paramsFrom: { - type: String, - required: false, - default: null, - }, - paramsTo: { - type: String, - required: false, - default: null, - }, - projectMergeRequestPath: { - type: String, - required: true, - }, - createMrPath: { - type: String, - required: true, - }, - }, - data() { - return { - from: this.paramsFrom, - to: this.paramsTo, - }; - }, - methods: { - onSubmit() { - this.$refs.form.submit(); - }, - onSwapRevision() { - [this.from, this.to] = [this.to, this.from]; // swaps 'from' and 'to' - }, - onSelectRevision({ direction, revision }) { - this[direction] = revision; // direction is either 'from' or 'to' - }, - }, -}; -</script> - -<template> - <form - ref="form" - class="form-inline js-requires-input js-signature-container" - method="POST" - :action="projectCompareIndexPath" - > - <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> - <revision-dropdown - :refs-project-path="refsProjectPath" - revision-text="Source" - params-name="to" - :params-branch="to" - data-testid="sourceRevisionDropdown" - @selectRevision="onSelectRevision" - /> - <div class="compare-ellipsis gl-display-inline" data-testid="ellipsis">...</div> - <revision-dropdown - :refs-project-path="refsProjectPath" - revision-text="Target" - params-name="from" - :params-branch="from" - data-testid="targetRevisionDropdown" - @selectRevision="onSelectRevision" - /> - <gl-button category="primary" variant="success" class="gl-ml-3" @click="onSubmit"> - {{ s__('CompareRevisions|Compare') }} - </gl-button> - <gl-button - data-testid="swapRevisionsButton" - class="btn btn-default gl-button gl-ml-3" - @click="onSwapRevision" - > - {{ s__('CompareRevisions|Swap revisions') }} - </gl-button> - <gl-button - v-if="projectMergeRequestPath" - :href="projectMergeRequestPath" - data-testid="projectMrButton" - class="btn btn-default gl-button gl-ml-3" - > - {{ s__('CompareRevisions|View open merge request') }} - </gl-button> - <gl-button - v-else-if="createMrPath" - :href="createMrPath" - data-testid="createMrButton" - class="btn btn-default gl-button gl-ml-3" - > - {{ s__('CompareRevisions|Create merge request') }} - </gl-button> - </form> -</template> diff --git a/app/assets/javascripts/projects/compare/index.js b/app/assets/javascripts/projects/compare/index.js index 322dff773b8..e485a086d39 100644 --- a/app/assets/javascripts/projects/compare/index.js +++ b/app/assets/javascripts/projects/compare/index.js @@ -1,44 +1,9 @@ import Vue from 'vue'; import CompareApp from './components/app.vue'; -import CompareAppLegacy from './components/app_legacy.vue'; export default function init() { const el = document.getElementById('js-compare-selector'); - if (gon.features?.compareRepoDropdown) { - const { - refsProjectPath, - paramsFrom, - paramsTo, - projectCompareIndexPath, - projectMergeRequestPath, - createMrPath, - projectTo, - projectsFrom, - } = el.dataset; - - return new Vue({ - el, - components: { - CompareApp, - }, - render(createElement) { - return createElement(CompareApp, { - props: { - refsProjectPath, - paramsFrom, - paramsTo, - projectCompareIndexPath, - projectMergeRequestPath, - createMrPath, - defaultProject: JSON.parse(projectTo), - projects: JSON.parse(projectsFrom), - }, - }); - }, - }); - } - const { refsProjectPath, paramsFrom, @@ -46,15 +11,17 @@ export default function init() { projectCompareIndexPath, projectMergeRequestPath, createMrPath, + projectTo, + projectsFrom, } = el.dataset; return new Vue({ el, components: { - CompareAppLegacy, + CompareApp, }, render(createElement) { - return createElement(CompareAppLegacy, { + return createElement(CompareApp, { props: { refsProjectPath, paramsFrom, @@ -62,6 +29,8 @@ export default function init() { projectCompareIndexPath, projectMergeRequestPath, createMrPath, + defaultProject: JSON.parse(projectTo), + projects: JSON.parse(projectsFrom), }, }); }, diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue index 0b0560f63c1..d3cadcd2bd5 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue @@ -239,7 +239,7 @@ export default { }; }, }, - successColor: '#608b2f', + successColor: '#366800', chartContainerHeight: CHART_CONTAINER_HEIGHT, timesChartOptions: { height: INNER_CHART_HEIGHT, diff --git a/app/assets/javascripts/projects/terraform_notification/components/terraform_notification.vue b/app/assets/javascripts/projects/terraform_notification/components/terraform_notification.vue index 0b398eddc9c..02e31d6fbb3 100644 --- a/app/assets/javascripts/projects/terraform_notification/components/terraform_notification.vue +++ b/app/assets/javascripts/projects/terraform_notification/components/terraform_notification.vue @@ -1,7 +1,7 @@ <script> import { GlBanner } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { parseBoolean, setCookie, getCookie } from '~/lib/utils/common_utils'; +import { setCookie } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; export default { @@ -16,50 +16,36 @@ export default { components: { GlBanner, }, - props: { - projectId: { - type: Number, - required: true, - }, - }, + inject: ['terraformImagePath', 'bannerDismissedKey'], data() { return { isVisible: true, }; }, computed: { - bannerDissmisedKey() { - return `terraform_notification_dismissed_for_project_${this.projectId}`; - }, docsUrl() { return helpPagePath('user/infrastructure/terraform_state'); }, }, - created() { - if (parseBoolean(getCookie(this.bannerDissmisedKey))) { - this.isVisible = false; - } - }, methods: { handleClose() { - setCookie(this.bannerDissmisedKey, true); + setCookie(this.bannerDismissedKey, true); this.isVisible = false; }, }, }; </script> <template> - <div v-if="isVisible"> - <div class="gl-py-5"> - <gl-banner - :title="$options.i18n.title" - :button-text="$options.i18n.buttonText" - :button-link="docsUrl" - variant="introduction" - @close="handleClose" - > - <p>{{ $options.i18n.description }}</p> - </gl-banner> - </div> + <div v-if="isVisible" class="gl-py-5"> + <gl-banner + :title="$options.i18n.title" + :button-text="$options.i18n.buttonText" + :button-link="docsUrl" + :svg-path="terraformImagePath" + variant="promotion" + @close="handleClose" + > + <p>{{ $options.i18n.description }}</p> + </gl-banner> </div> </template> diff --git a/app/assets/javascripts/projects/terraform_notification/index.js b/app/assets/javascripts/projects/terraform_notification/index.js index eb04f109a8e..0a273247930 100644 --- a/app/assets/javascripts/projects/terraform_notification/index.js +++ b/app/assets/javascripts/projects/terraform_notification/index.js @@ -1,18 +1,23 @@ import Vue from 'vue'; +import { parseBoolean, getCookie } from '~/lib/utils/common_utils'; import TerraformNotification from './components/terraform_notification.vue'; export default () => { const el = document.querySelector('.js-terraform-notification'); + const bannerDismissedKey = 'terraform_notification_dismissed'; - if (!el) { + if (!el || parseBoolean(getCookie(bannerDismissedKey))) { return false; } - const { projectId } = el.dataset; + const { terraformImagePath } = el.dataset; return new Vue({ el, - render: (createElement) => - createElement(TerraformNotification, { props: { projectId: Number(projectId) } }), + provide: { + terraformImagePath, + bannerDismissedKey, + }, + render: (createElement) => createElement(TerraformNotification), }); }; diff --git a/app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue b/app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue index 0432cf1123c..f857c96c9d1 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue @@ -1,5 +1,5 @@ <script> -import { GlModal, GlSprintf } from '@gitlab/ui'; +import { GlModal, GlSprintf, GlFormInput } from '@gitlab/ui'; import { n__ } from '~/locale'; import { REMOVE_TAG_CONFIRMATION_TEXT, @@ -12,6 +12,7 @@ export default { components: { GlModal, GlSprintf, + GlFormInput, }, props: { itemsToBeDeleted: { @@ -25,7 +26,15 @@ export default { required: false, }, }, + data() { + return { + projectPath: '', + }; + }, computed: { + imageProjectPath() { + return this.itemsToBeDeleted[0]?.project?.path; + }, modalTitle() { if (this.deleteImage) { return DELETE_IMAGE_CONFIRMATION_TITLE; @@ -40,6 +49,7 @@ export default { if (this.deleteImage) { return { message: DELETE_IMAGE_CONFIRMATION_TEXT, + item: this.imageProjectPath, }; } if (this.itemsToBeDeleted.length > 1) { @@ -55,6 +65,9 @@ export default { item: first?.path, }; }, + disablePrimaryButton() { + return this.deleteImage && this.projectPath !== this.imageProjectPath; + }, }, methods: { show() { @@ -69,10 +82,14 @@ export default { ref="deleteModal" modal-id="delete-tag-modal" ok-variant="danger" - :action-primary="{ text: __('Confirm'), attributes: { variant: 'danger' } }" + :action-primary="{ + text: __('Delete'), + attributes: [{ variant: 'danger' }, { disabled: disablePrimaryButton }], + }" :action-cancel="{ text: __('Cancel') }" @primary="$emit('confirmDelete')" @cancel="$emit('cancelDelete')" + @change="projectPath = ''" > <template #modal-title>{{ modalTitle }}</template> <p v-if="modalDescription" data-testid="description"> @@ -80,7 +97,13 @@ export default { <template #item> <b>{{ modalDescription.item }}</b> </template> + <template #code> + <code>{{ modalDescription.item }}</code> + </template> </gl-sprintf> </p> + <div v-if="deleteImage"> + <gl-form-input v-model="projectPath" /> + </div> </gl-modal> </template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue index 80ed9a32039..e9e36151fe6 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { sprintf, n__, s__ } from '~/locale'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; @@ -27,7 +27,7 @@ import getContainerRepositoryTagsCountQuery from '../../graphql/queries/get_cont export default { name: 'DetailsHeader', - components: { GlButton, GlIcon, TitleArea, MetadataItem }, + components: { GlIcon, TitleArea, MetadataItem, GlDropdown, GlDropdownItem }, directives: { GlTooltip: GlTooltipDirective, }, @@ -143,9 +143,22 @@ export default { /> </template> <template #right-actions> - <gl-button variant="danger" :disabled="deleteButtonDisabled" @click="$emit('delete')"> - {{ __('Delete image repository') }} - </gl-button> + <gl-dropdown + icon="ellipsis_v" + text="More actions" + :text-sr-only="true" + category="tertiary" + no-caret + right + > + <gl-dropdown-item + variant="danger" + :disabled="deleteButtonDisabled" + @click="$emit('delete')" + > + {{ __('Delete image repository') }} + </gl-dropdown-item> + </gl-dropdown> </template> </title-area> </template> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/cleanup_status.vue b/app/assets/javascripts/registry/explorer/components/list_page/cleanup_status.vue index 8d9e221af4c..1f52e319ad0 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/cleanup_status.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/cleanup_status.vue @@ -1,7 +1,7 @@ <script> import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { - ASYNC_DELETE_IMAGE_ERROR_MESSAGE, + CLEANUP_TIMED_OUT_ERROR_MESSAGE, CLEANUP_STATUS_SCHEDULED, CLEANUP_STATUS_ONGOING, CLEANUP_STATUS_UNFINISHED, @@ -34,7 +34,7 @@ export default { CLEANUP_STATUS_SCHEDULED, CLEANUP_STATUS_ONGOING, CLEANUP_STATUS_UNFINISHED, - ASYNC_DELETE_IMAGE_ERROR_MESSAGE, + CLEANUP_TIMED_OUT_ERROR_MESSAGE, }, computed: { showStatus() { @@ -61,7 +61,7 @@ export default { </span> <gl-icon v-if="failedDelete" - v-gl-tooltip="{ title: $options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE }" + v-gl-tooltip="{ title: $options.i18n.CLEANUP_TIMED_OUT_ERROR_MESSAGE }" :size="14" class="gl-text-black-normal" data-testid="extra-info" diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js index 9b4c06349e2..0836260b71e 100644 --- a/app/assets/javascripts/registry/explorer/constants/details.js +++ b/app/assets/javascripts/registry/explorer/constants/details.js @@ -99,7 +99,7 @@ export const DETAILS_DELETE_IMAGE_ERROR_MESSAGE = s__( export const DELETE_IMAGE_CONFIRMATION_TITLE = s__('ContainerRegistry|Delete image repository?'); export const DELETE_IMAGE_CONFIRMATION_TEXT = s__( - 'ContainerRegistry|Deleting the image repository will delete all images and tags inside. This action cannot be undone.', + 'ContainerRegistry|Deleting the image repository will delete all images and tags inside. This action cannot be undone. Please type the following to confirm: %{code}', ); export const SCHEDULED_FOR_DELETION_STATUS_TITLE = s__( @@ -162,6 +162,9 @@ export const IMAGE_STATUS_ALERT_TYPE = { [DELETE_FAILED]: 'warning', }; -export const PACKAGE_DELETE_HELP_PAGE_PATH = helpPagePath('user/packages/container_registry', { - anchor: 'delete-images', -}); +export const PACKAGE_DELETE_HELP_PAGE_PATH = helpPagePath( + 'user/packages/container_registry/index', + { + anchor: 'delete-images', + }, +); diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql index 88c2e667afd..b5a99fd9ac1 100644 --- a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql +++ b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql @@ -12,6 +12,7 @@ query getContainerRepositoryDetails($id: ID!) { expirationPolicyCleanupStatus project { visibility + path containerExpirationPolicy { enabled nextRunAt diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue index 34ec3b085a5..feabc4f770b 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -161,7 +161,7 @@ export default { }, deleteImage() { this.deleteImageAlert = true; - this.itemsToBeDeleted = [{ path: this.containerRepository.path }]; + this.itemsToBeDeleted = [{ ...this.containerRepository }]; this.$refs.deleteModal.show(); }, deleteImageError() { @@ -188,7 +188,7 @@ export default { <partial-cleanup-alert v-if="showPartialCleanupWarning" :run-cleanup-policies-help-page-path="config.runCleanupPoliciesHelpPagePath" - :cleanup-policies-help-page-path="config.cleanupPoliciesHelpPagePath" + :cleanup-policies-help-page-path="config.expirationPolicyHelpPagePath" @dismiss="dismissPartialCleanupWarning" /> diff --git a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue index e568950380e..0e18d0992cd 100644 --- a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue +++ b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue @@ -12,19 +12,10 @@ export default { ReportSection, }, props: { - headPath: { - type: String, - required: true, - }, headBlobPath: { type: String, required: true, }, - basePath: { - type: String, - required: false, - default: null, - }, baseBlobPath: { type: String, required: false, @@ -52,8 +43,6 @@ export default { }, created() { this.setPaths({ - basePath: this.basePath, - headPath: this.headPath, baseBlobPath: this.baseBlobPath, headBlobPath: this.headBlobPath, reportsPath: this.codequalityReportsPath, diff --git a/app/assets/javascripts/reports/codequality_report/store/actions.js b/app/assets/javascripts/reports/codequality_report/store/actions.js index e3238207af2..04aca11b945 100644 --- a/app/assets/javascripts/reports/codequality_report/store/actions.js +++ b/app/assets/javascripts/reports/codequality_report/store/actions.js @@ -1,4 +1,5 @@ -import axios from '~/lib/utils/axios_utils'; +import pollUntilComplete from '~/lib/utils/poll_until_complete'; +import { STATUS_NOT_FOUND } from '../../constants'; import * as types from './mutation_types'; import { parseCodeclimateMetrics } from './utils/codequality_parser'; @@ -7,12 +8,11 @@ export const setPaths = ({ commit }, paths) => commit(types.SET_PATHS, paths); export const fetchReports = ({ state, dispatch, commit }) => { commit(types.REQUEST_REPORTS); - if (!state.basePath) { - return dispatch('receiveReportsError'); - } - return axios - .get(state.reportsPath) + return pollUntilComplete(state.reportsPath) .then(({ data }) => { + if (data.status === STATUS_NOT_FOUND) { + return dispatch('receiveReportsError', data); + } return dispatch('receiveReportsSuccess', { newIssues: parseCodeclimateMetrics(data.new_errors, state.headBlobPath), resolvedIssues: parseCodeclimateMetrics(data.resolved_errors, state.baseBlobPath), diff --git a/app/assets/javascripts/reports/codequality_report/store/getters.js b/app/assets/javascripts/reports/codequality_report/store/getters.js index c6935291af2..3fb8c5be351 100644 --- a/app/assets/javascripts/reports/codequality_report/store/getters.js +++ b/app/assets/javascripts/reports/codequality_report/store/getters.js @@ -1,6 +1,6 @@ import { spriteIcon } from '~/lib/utils/common_utils'; import { sprintf, __, s__, n__ } from '~/locale'; -import { LOADING, ERROR, SUCCESS } from '../../constants'; +import { LOADING, ERROR, SUCCESS, STATUS_NOT_FOUND } from '../../constants'; export const hasCodequalityIssues = (state) => Boolean(state.newIssues?.length || state.resolvedIssues?.length); @@ -42,7 +42,7 @@ export const codequalityText = (state) => { }; export const codequalityPopover = (state) => { - if (state.headPath && !state.basePath) { + if (state.status === STATUS_NOT_FOUND) { return { title: s__('ciReport|Base pipeline codequality artifact not found'), content: sprintf( diff --git a/app/assets/javascripts/reports/codequality_report/store/mutations.js b/app/assets/javascripts/reports/codequality_report/store/mutations.js index 095e6637966..249c2f35c0b 100644 --- a/app/assets/javascripts/reports/codequality_report/store/mutations.js +++ b/app/assets/javascripts/reports/codequality_report/store/mutations.js @@ -2,8 +2,6 @@ import * as types from './mutation_types'; export default { [types.SET_PATHS](state, paths) { - state.basePath = paths.basePath; - state.headPath = paths.headPath; state.baseBlobPath = paths.baseBlobPath; state.headBlobPath = paths.headBlobPath; state.reportsPath = paths.reportsPath; @@ -14,6 +12,7 @@ export default { }, [types.RECEIVE_REPORTS_SUCCESS](state, data) { state.hasError = false; + state.status = ''; state.statusReason = ''; state.isLoading = false; state.newIssues = data.newIssues; @@ -22,6 +21,7 @@ export default { [types.RECEIVE_REPORTS_ERROR](state, error) { state.isLoading = false; state.hasError = true; + state.status = error?.status || ''; state.statusReason = error?.response?.data?.status_reason; }, }; diff --git a/app/assets/javascripts/reports/codequality_report/store/state.js b/app/assets/javascripts/reports/codequality_report/store/state.js index b39ff4f9d66..f68dbc2a5fa 100644 --- a/app/assets/javascripts/reports/codequality_report/store/state.js +++ b/app/assets/javascripts/reports/codequality_report/store/state.js @@ -1,6 +1,4 @@ export default () => ({ - basePath: null, - headPath: null, reportsPath: null, baseBlobPath: null, @@ -8,6 +6,7 @@ export default () => ({ isLoading: false, hasError: false, + status: '', statusReason: '', newIssues: [], diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js index 7f7ea2adc0e..53273aeff33 100644 --- a/app/assets/javascripts/reports/constants.js +++ b/app/assets/javascripts/reports/constants.js @@ -12,6 +12,7 @@ export const SUCCESS = 'SUCCESS'; export const STATUS_FAILED = 'failed'; export const STATUS_SUCCESS = 'success'; export const STATUS_NEUTRAL = 'neutral'; +export const STATUS_NOT_FOUND = 'not_found'; export const ICON_WARNING = 'warning'; export const ICON_SUCCESS = 'success'; diff --git a/app/assets/javascripts/reports/grouped_test_report/components/modal.vue b/app/assets/javascripts/reports/grouped_test_report/components/modal.vue index af93e5bc639..ca518aea743 100644 --- a/app/assets/javascripts/reports/grouped_test_report/components/modal.vue +++ b/app/assets/javascripts/reports/grouped_test_report/components/modal.vue @@ -47,7 +47,7 @@ export default { <div v-for="(field, key, index) in filteredModalData" :key="index" class="row gl-mt-3 gl-mb-3"> <strong class="col-sm-3 text-right"> {{ field.text }}: </strong> - <div class="col-sm-9 text-secondary"> + <div class="col-sm-9"> <code-block v-if="field.type === $options.fieldTypes.codeBlock" :code="field.value" /> <gl-link diff --git a/app/assets/javascripts/repository/components/blob_button_group.vue b/app/assets/javascripts/repository/components/blob_button_group.vue index 273825b996a..4e7ca7b17e4 100644 --- a/app/assets/javascripts/repository/components/blob_button_group.vue +++ b/app/assets/javascripts/repository/components/blob_button_group.vue @@ -2,6 +2,7 @@ import { GlButtonGroup, GlButton, GlModalDirective } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { sprintf, __ } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import getRefMixin from '../mixins/get_ref'; import DeleteBlobModal from './delete_blob_modal.vue'; import UploadBlobModal from './upload_blob_modal.vue'; @@ -17,11 +18,12 @@ export default { GlButton, UploadBlobModal, DeleteBlobModal, + LockButton: () => import('ee_component/repository/components/lock_button.vue'), }, directives: { GlModal: GlModalDirective, }, - mixins: [getRefMixin], + mixins: [getRefMixin, glFeatureFlagMixin()], inject: { targetBranch: { default: '', @@ -55,6 +57,18 @@ export default { type: Boolean, required: true, }, + projectPath: { + type: String, + required: true, + }, + isLocked: { + type: Boolean, + required: true, + }, + canLock: { + type: Boolean, + required: true, + }, }, computed: { replaceModalId() { @@ -76,10 +90,19 @@ export default { <template> <div class="gl-mr-3"> <gl-button-group> - <gl-button v-gl-modal="replaceModalId"> + <lock-button + v-if="glFeatures.fileLocks" + :name="name" + :path="path" + :project-path="projectPath" + :is-locked="isLocked" + :can-lock="canLock" + data-testid="lock" + /> + <gl-button v-gl-modal="replaceModalId" data-testid="replace"> {{ $options.i18n.replace }} </gl-button> - <gl-button v-gl-modal="deleteModalId"> + <gl-button v-gl-modal="deleteModalId" data-testid="delete"> {{ $options.i18n.delete }} </gl-button> </gl-button-group> diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 09ac60c94c7..665b0698cc0 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -8,6 +8,7 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; +import getRefMixin from '../mixins/get_ref'; import blobInfoQuery from '../queries/blob_info.query.graphql'; import BlobButtonGroup from './blob_button_group.vue'; import BlobEdit from './blob_edit.vue'; @@ -21,6 +22,12 @@ export default { BlobContent, GlLoadingIcon, }, + mixins: [getRefMixin], + inject: { + originalBranch: { + default: '', + }, + }, apollo: { project: { query: blobInfoQuery, @@ -28,6 +35,7 @@ export default { return { projectPath: this.projectPath, filePath: this.path, + ref: this.originalBranch || this.ref, }; }, result() { @@ -67,6 +75,10 @@ export default { project: { userPermissions: { pushCode: false, + downloadCode: false, + }, + pathLocks: { + nodes: [], }, repository: { empty: true, @@ -87,9 +99,6 @@ export default { externalStorageUrl: '', replacePath: '', deletePath: '', - canLock: false, - isLocked: false, - lockLink: '', forkPath: '', simpleViewer: {}, richViewer: null, @@ -108,8 +117,11 @@ export default { isLoading() { return this.$apollo.queries.project.loading || this.isLoadingLegacyViewer; }, + isBinaryFileType() { + return this.isBinary || this.viewer.fileType === 'download'; + }, blobInfo() { - const nodes = this.project?.repository?.blobs?.nodes; + const nodes = this.project?.repository?.blobs?.nodes || []; return nodes[0] || {}; }, @@ -131,6 +143,14 @@ export default { const { fileType } = this.viewer; return viewerProps(fileType, this.blobInfo); }, + canLock() { + const { pushCode, downloadCode } = this.project.userPermissions; + + return pushCode && downloadCode; + }, + isLocked() { + return this.project.pathLocks.nodes.some((node) => node.path === this.path); + }, }, methods: { loadLegacyViewer() { @@ -161,13 +181,14 @@ export default { <blob-header :blob="blobInfo" :hide-viewer-switcher="!hasRichViewer || isBinary" + :is-binary="isBinaryFileType" :active-viewer-type="viewer.type" :has-render-error="hasRenderError" @viewer-changed="switchViewer" > <template #actions> <blob-edit - v-if="!isBinary" + :show-edit-button="!isBinary" :edit-path="blobInfo.editBlobPath" :web-ide-path="blobInfo.ideEditPath" /> @@ -179,6 +200,9 @@ export default { :delete-path="blobInfo.webPath" :can-push-code="project.userPermissions.pushCode" :empty-repo="project.repository.empty" + :project-path="projectPath" + :is-locked="isLocked" + :can-lock="canLock" /> </template> </blob-header> diff --git a/app/assets/javascripts/repository/components/blob_edit.vue b/app/assets/javascripts/repository/components/blob_edit.vue index 3d97ebe89e4..30ed4cd57f1 100644 --- a/app/assets/javascripts/repository/components/blob_edit.vue +++ b/app/assets/javascripts/repository/components/blob_edit.vue @@ -15,6 +15,10 @@ export default { }, mixins: [glFeatureFlagsMixin()], props: { + showEditButton: { + type: Boolean, + required: true, + }, editPath: { type: String, required: true, @@ -30,17 +34,31 @@ export default { <template> <web-ide-link v-if="glFeatures.consolidatedEditButton" + :show-edit-button="showEditButton" class="gl-mr-3" :edit-url="editPath" :web-ide-url="webIdePath" :is-blob="true" /> <div v-else> - <gl-button class="gl-mr-2" category="primary" variant="confirm" :href="editPath"> + <gl-button + v-if="showEditButton" + class="gl-mr-2" + category="primary" + variant="confirm" + :href="editPath" + data-testid="edit" + > {{ $options.i18n.edit }} </gl-button> - <gl-button class="gl-mr-3" category="primary" variant="confirm" :href="webIdePath"> + <gl-button + class="gl-mr-3" + category="primary" + variant="confirm" + :href="webIdePath" + data-testid="web-ide" + > {{ $options.i18n.webIde }} </gl-button> </div> diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index 0b8408643ac..db84e2b5912 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -247,7 +247,8 @@ export default { return items; }, renderAddToTreeDropdown() { - return this.canCollaborate || this.canCreateMrFromFork; + const isBlobPath = this.$route.name === 'blobPath' || this.$route.name === 'blobPathDecoded'; + return !isBlobPath && (this.canCollaborate || this.canCreateMrFromFork); }, }, methods: { diff --git a/app/assets/javascripts/repository/components/delete_blob_modal.vue b/app/assets/javascripts/repository/components/delete_blob_modal.vue index 6599d99d7bd..a307b7c0b8a 100644 --- a/app/assets/javascripts/repository/components/delete_blob_modal.vue +++ b/app/assets/javascripts/repository/components/delete_blob_modal.vue @@ -1,14 +1,24 @@ <script> -import { GlModal, GlFormGroup, GlFormInput, GlFormTextarea, GlToggle } from '@gitlab/ui'; +import { GlModal, GlFormGroup, GlFormInput, GlFormTextarea, GlToggle, GlForm } from '@gitlab/ui'; import csrf from '~/lib/utils/csrf'; import { __ } from '~/locale'; +import validation from '~/vue_shared/directives/validation'; import { SECONDARY_OPTIONS_TEXT, COMMIT_LABEL, TARGET_BRANCH_LABEL, TOGGLE_CREATE_MR_LABEL, + COMMIT_MESSAGE_SUBJECT_MAX_LENGTH, + COMMIT_MESSAGE_BODY_MAX_LENGTH, } from '../constants'; +const initFormField = ({ value, required = true, skipValidation = false }) => ({ + value, + required, + state: skipValidation ? true : null, + feedback: null, +}); + export default { csrf, components: { @@ -17,6 +27,7 @@ export default { GlFormInput, GlFormTextarea, GlToggle, + GlForm, }, i18n: { PRIMARY_OPTIONS_TEXT: __('Delete file'), @@ -24,6 +35,12 @@ export default { COMMIT_LABEL, TARGET_BRANCH_LABEL, TOGGLE_CREATE_MR_LABEL, + COMMIT_MESSAGE_HINT: __( + 'Try to keep the first line under 52 characters and the others under 72.', + ), + }, + directives: { + validation: validation(), }, props: { modalId: { @@ -60,12 +77,20 @@ export default { }, }, data() { + const form = { + state: false, + showValidation: false, + fields: { + // fields key must match case of form name for validation directive to work + commit_message: initFormField({ value: this.commitMessage }), + branch_name: initFormField({ value: this.targetBranch }), + }, + }; return { loading: false, - commit: this.commitMessage, - target: this.targetBranch, createNewMr: true, error: '', + form, }; }, computed: { @@ -76,7 +101,7 @@ export default { { variant: 'danger', loading: this.loading, - disabled: !this.formCompleted || this.loading, + disabled: this.loading || !this.form.state, }, ], }; @@ -91,18 +116,44 @@ export default { ], }; }, + /* eslint-disable dot-notation */ showCreateNewMrToggle() { - return this.canPushCode && this.target !== this.originalBranch; + return this.canPushCode && this.form.fields['branch_name'].value !== this.originalBranch; }, formCompleted() { - return this.commit && this.target; + return this.form.fields['commit_message'].value && this.form.fields['branch_name'].value; }, + showHint() { + const splitCommitMessageByLineBreak = this.form.fields['commit_message'].value + .trim() + .split('\n'); + const [firstLine, ...otherLines] = splitCommitMessageByLineBreak; + + const hasFirstLineExceedMaxLength = firstLine.length > COMMIT_MESSAGE_SUBJECT_MAX_LENGTH; + + const hasOtherLineExceedMaxLength = + Boolean(otherLines.length) && + otherLines.some((text) => text.length > COMMIT_MESSAGE_BODY_MAX_LENGTH); + + return ( + !this.form.fields['commit_message'].feedback && + (hasFirstLineExceedMaxLength || hasOtherLineExceedMaxLength) + ); + }, + /* eslint-enable dot-notation */ }, methods: { submitForm(e) { e.preventDefault(); // Prevent modal from closing + this.form.showValidation = true; + + if (!this.form.state) { + return; + } + this.loading = true; - this.$refs.form.submit(); + this.form.showValidation = false; + this.$refs.form.$el.submit(); }, }, }; @@ -110,13 +161,15 @@ export default { <template> <gl-modal + v-bind="$attrs" + data-testid="modal-delete" :modal-id="modalId" :title="modalTitle" :action-primary="primaryOptions" :action-cancel="cancelOptions" @primary="submitForm" > - <form ref="form" :action="deletePath" method="post"> + <gl-form ref="form" novalidate :action="deletePath" method="post"> <input type="hidden" name="_method" value="delete" /> <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> <template v-if="emptyRepo"> @@ -129,15 +182,37 @@ export default { <!-- Once "push to branch" permission is made available, will need to add to conditional Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/335462 --> <input v-if="createNewMr" type="hidden" name="create_merge_request" value="1" /> - <gl-form-group :label="$options.i18n.COMMIT_LABEL" label-for="commit_message"> - <gl-form-textarea v-model="commit" name="commit_message" :disabled="loading" /> + <gl-form-group + :label="$options.i18n.COMMIT_LABEL" + label-for="commit_message" + :invalid-feedback="form.fields['commit_message'].feedback" + > + <gl-form-textarea + v-model="form.fields['commit_message'].value" + v-validation:[form.showValidation] + name="commit_message" + :state="form.fields['commit_message'].state" + :disabled="loading" + required + /> + <p v-if="showHint" class="form-text gl-text-gray-600" data-testid="hint"> + {{ $options.i18n.COMMIT_MESSAGE_HINT }} + </p> </gl-form-group> <gl-form-group v-if="canPushCode" :label="$options.i18n.TARGET_BRANCH_LABEL" label-for="branch_name" + :invalid-feedback="form.fields['branch_name'].feedback" > - <gl-form-input v-model="target" :disabled="loading" name="branch_name" /> + <gl-form-input + v-model="form.fields['branch_name'].value" + v-validation:[form.showValidation] + :state="form.fields['branch_name'].state" + :disabled="loading" + name="branch_name" + required + /> </gl-form-group> <gl-toggle v-if="showCreateNewMrToggle" @@ -146,6 +221,6 @@ export default { :label="$options.i18n.TOGGLE_CREATE_MR_LABEL" /> </template> - </form> + </gl-form> </gl-modal> </template> diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 82c18d13a6a..fa358a75cc1 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -170,6 +170,7 @@ export default { this.apolloQuery(blobInfoQuery, { projectPath: this.projectPath, filePath: this.path, + ref: this.ref, }); }, apolloQuery(query, variables) { diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index 2d2faa8d9f3..b536bcb1875 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -8,3 +8,6 @@ export const SECONDARY_OPTIONS_TEXT = __('Cancel'); export const COMMIT_LABEL = __('Commit message'); export const TARGET_BRANCH_LABEL = __('Target branch'); export const TOGGLE_CREATE_MR_LABEL = __('Start a new merge request with these changes'); + +export const COMMIT_MESSAGE_SUBJECT_MAX_LENGTH = 52; +export const COMMIT_MESSAGE_BODY_MAX_LENGTH = 72; diff --git a/app/assets/javascripts/repository/mutations/lock_path.mutation.graphql b/app/assets/javascripts/repository/mutations/lock_path.mutation.graphql new file mode 100644 index 00000000000..eaebc4ddf17 --- /dev/null +++ b/app/assets/javascripts/repository/mutations/lock_path.mutation.graphql @@ -0,0 +1,13 @@ +mutation toggleLock($projectPath: ID!, $filePath: String!, $lock: Boolean!) { + projectSetLocked(input: { projectPath: $projectPath, filePath: $filePath, lock: $lock }) { + project { + id + pathLocks { + nodes { + path + } + } + } + errors + } +} diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql index a8f263941e2..45f07f7dc58 100644 --- a/app/assets/javascripts/repository/queries/blob_info.query.graphql +++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql @@ -1,11 +1,18 @@ -query getBlobInfo($projectPath: ID!, $filePath: String!) { +query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) { project(fullPath: $projectPath) { + id userPermissions { pushCode + downloadCode + } + pathLocks { + nodes { + path + } } repository { empty - blobs(paths: [$filePath]) { + blobs(paths: [$filePath], ref: $ref) { nodes { webPath name diff --git a/app/assets/javascripts/runner/runner_list/runner_list_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue index 8d39243d609..23ecee449a4 100644 --- a/app/assets/javascripts/runner/runner_list/runner_list_app.vue +++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue @@ -9,15 +9,15 @@ import RunnerPagination from '../components/runner_pagination.vue'; import RunnerTypeHelp from '../components/runner_type_help.vue'; import { INSTANCE_TYPE, I18N_FETCH_ERROR } from '../constants'; import getRunnersQuery from '../graphql/get_runners.query.graphql'; -import { captureException } from '../sentry_utils'; import { fromUrlQueryToSearch, fromSearchToUrl, fromSearchToVariables, -} from './runner_search_utils'; +} from '../runner_search_utils'; +import { captureException } from '../sentry_utils'; export default { - name: 'RunnerListApp', + name: 'AdminRunnersApp', components: { RunnerFilteredSearchBar, RunnerList, diff --git a/app/assets/javascripts/runner/runner_list/index.js b/app/assets/javascripts/runner/admin_runners/index.js index 16616f00d1e..1eec1019b73 100644 --- a/app/assets/javascripts/runner/runner_list/index.js +++ b/app/assets/javascripts/runner/admin_runners/index.js @@ -1,11 +1,11 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import RunnerDetailsApp from './runner_list_app.vue'; +import AdminRunnersApp from './admin_runners_app.vue'; Vue.use(VueApollo); -export const initRunnerList = (selector = '#js-runner-list') => { +export const initAdminRunners = (selector = '#js-admin-runners') => { const el = document.querySelector(selector); if (!el) { @@ -32,7 +32,7 @@ export const initRunnerList = (selector = '#js-runner-list') => { runnerInstallHelpPage, }, render(h) { - return h(RunnerDetailsApp, { + return h(AdminRunnersApp, { props: { activeRunnersCount: parseInt(activeRunnersCount, 10), registrationToken, diff --git a/app/assets/javascripts/runner/components/runner_registration_token_reset.vue b/app/assets/javascripts/runner/components/runner_registration_token_reset.vue index 2335faa4f85..cdf14abd4f9 100644 --- a/app/assets/javascripts/runner/components/runner_registration_token_reset.vue +++ b/app/assets/javascripts/runner/components/runner_registration_token_reset.vue @@ -1,6 +1,8 @@ <script> import { GlButton } from '@gitlab/ui'; import createFlash, { FLASH_TYPES } from '~/flash'; +import { TYPE_GROUP, TYPE_PROJECT } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; import { __, s__ } from '~/locale'; import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql'; import { captureException } from '~/runner/sentry_utils'; @@ -11,6 +13,14 @@ export default { components: { GlButton, }, + inject: { + groupId: { + default: null, + }, + projectId: { + default: null, + }, + }, props: { type: { type: String, @@ -25,7 +35,28 @@ export default { loading: false, }; }, - computed: {}, + computed: { + resetTokenInput() { + switch (this.type) { + case INSTANCE_TYPE: + return { + type: this.type, + }; + case GROUP_TYPE: + return { + id: convertToGraphQLId(TYPE_GROUP, this.groupId), + type: this.type, + }; + case PROJECT_TYPE: + return { + id: convertToGraphQLId(TYPE_PROJECT, this.projectId), + type: this.type, + }; + default: + return null; + } + }, + }, methods: { async resetToken() { // TODO Replace confirmation with gl-modal @@ -44,13 +75,7 @@ export default { } = await this.$apollo.mutate({ mutation: runnersRegistrationTokenResetMutation, variables: { - // TODO Currently INTANCE_TYPE only is supported - // In future iterations this component will support - // other registration token types. - // See: https://gitlab.com/gitlab-org/gitlab/-/issues/19819 - input: { - type: this.type, - }, + input: this.resetTokenInput, }, }); if (errors && errors.length) { diff --git a/app/assets/javascripts/runner/components/runner_type_alert.vue b/app/assets/javascripts/runner/components/runner_type_alert.vue index 72ce582e02c..aa435aaa823 100644 --- a/app/assets/javascripts/runner/components/runner_type_alert.vue +++ b/app/assets/javascripts/runner/components/runner_type_alert.vue @@ -6,28 +6,19 @@ import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants'; const ALERT_DATA = { [INSTANCE_TYPE]: { - title: s__( - 'Runners|This runner is available to all groups and projects in your GitLab instance.', - ), message: s__( - 'Runners|Shared runners are available to every project in a GitLab instance. If you want a runner to build only specific projects, restrict the project in the table below. After you restrict a runner to a project, you cannot change it back to a shared runner.', + 'Runners|This runner is available to all groups and projects in your GitLab instance.', ), variant: 'success', anchor: 'shared-runners', }, [GROUP_TYPE]: { - title: s__('Runners|This runner is available to all projects and subgroups in a group.'), - message: s__( - 'Runners|Use Group runners when you want all projects in a group to have access to a set of runners.', - ), + message: s__('Runners|This runner is available to all projects and subgroups in a group.'), variant: 'success', anchor: 'group-runners', }, [PROJECT_TYPE]: { - title: s__('Runners|This runner is associated with specific projects.'), - message: s__( - 'Runners|You can set up a specific runner to be used by multiple projects but you cannot make this a shared runner.', - ), + message: s__('Runners|This runner is associated with one or more projects.'), variant: 'info', anchor: 'specific-runners', }, @@ -59,7 +50,7 @@ export default { }; </script> <template> - <gl-alert v-if="alert" :variant="alert.variant" :title="alert.title" :dismissible="false"> + <gl-alert v-if="alert" :variant="alert.variant" :dismissible="false"> {{ alert.message }} <gl-link :href="helpHref">{{ __('Learn more.') }}</gl-link> </gl-alert> diff --git a/app/assets/javascripts/runner/components/runner_update_form.vue b/app/assets/javascripts/runner/components/runner_update_form.vue index 85d14547efd..a5bc1680852 100644 --- a/app/assets/javascripts/runner/components/runner_update_form.vue +++ b/app/assets/javascripts/runner/components/runner_update_form.vue @@ -111,7 +111,7 @@ export default { > {{ __('Paused') }} <template #help> - {{ __("Paused runners don't accept new jobs") }} + {{ s__('Runners|Stop the runner from accepting new jobs.') }} </template> </gl-form-checkbox> @@ -123,14 +123,14 @@ export default { > {{ __('Protected') }} <template #help> - {{ __('This runner will only run on pipelines triggered on protected branches') }} + {{ s__('Runners|Use the runner on pipelines for protected branches only.') }} </template> </gl-form-checkbox> <gl-form-checkbox v-model="model.runUntagged" data-testid="runner-field-run-untagged"> {{ __('Run untagged jobs') }} <template #help> - {{ __('Indicates whether this runner can pick jobs without tags') }} + {{ s__('Runners|Use the runner for jobs without tags, in addition to tagged jobs.') }} </template> </gl-form-checkbox> @@ -141,7 +141,7 @@ export default { > {{ __('Lock to current projects') }} <template #help> - {{ __('When a runner is locked, it cannot be assigned to other projects') }} + {{ s__('Runners|Use the runner for the currently assigned projects only.') }} </template> </gl-form-checkbox> diff --git a/app/assets/javascripts/runner/components/search_tokens/tag_token.vue b/app/assets/javascripts/runner/components/search_tokens/tag_token.vue index 0c69072f06a..51fae60b6b7 100644 --- a/app/assets/javascripts/runner/components/search_tokens/tag_token.vue +++ b/app/assets/javascripts/runner/components/search_tokens/tag_token.vue @@ -28,11 +28,6 @@ export default { }; }, methods: { - fnCurrentTokenValue(data) { - // By default, values are transformed with `toLowerCase` - // however, runner tags are case sensitive. - return data; - }, getTagsOptions(search) { // TODO This should be implemented via a GraphQL API // The API should @@ -72,7 +67,6 @@ export default { :config="config" :suggestions-loading="loading" :suggestions="tags" - :fn-current-token-value="fnCurrentTokenValue" :recent-suggestions-storage-key="config.recentTokenValuesStorageKey" @fetch-suggestions="fetchTags" v-on="$listeners" diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue new file mode 100644 index 00000000000..07bbf60c453 --- /dev/null +++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue @@ -0,0 +1,35 @@ +<script> +import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue'; +import RunnerTypeHelp from '../components/runner_type_help.vue'; +import { GROUP_TYPE } from '../constants'; + +export default { + components: { + RunnerManualSetupHelp, + RunnerTypeHelp, + }, + props: { + registrationToken: { + type: String, + required: true, + }, + }, + GROUP_TYPE, +}; +</script> + +<template> + <div> + <div class="row"> + <div class="col-sm-6"> + <runner-type-help /> + </div> + <div class="col-sm-6"> + <runner-manual-setup-help + :registration-token="registrationToken" + :type="$options.GROUP_TYPE" + /> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/runner/group_runners/index.js b/app/assets/javascripts/runner/group_runners/index.js new file mode 100644 index 00000000000..e14c583d73e --- /dev/null +++ b/app/assets/javascripts/runner/group_runners/index.js @@ -0,0 +1,40 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import GroupRunnersApp from './group_runners_app.vue'; + +Vue.use(VueApollo); + +export const initGroupRunners = (selector = '#js-group-runners') => { + const el = document.querySelector(selector); + + if (!el) { + return null; + } + + const { registrationToken, groupId } = el.dataset; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + assumeImmutableResults: true, + }, + ), + }); + + return new Vue({ + el, + apolloProvider, + provide: { + groupId, + }, + render(h) { + return h(GroupRunnersApp, { + props: { + registrationToken, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/runner/runner_list/runner_search_utils.js b/app/assets/javascripts/runner/runner_search_utils.js index 9a0dc9c3a32..65f75eb11ac 100644 --- a/app/assets/javascripts/runner/runner_list/runner_search_utils.js +++ b/app/assets/javascripts/runner/runner_search_utils.js @@ -16,7 +16,7 @@ import { PARAM_KEY_BEFORE, DEFAULT_SORT, RUNNER_PAGE_SIZE, -} from '../constants'; +} from './constants'; const getPaginationFromParams = (params) => { const page = parseInt(params[PARAM_KEY_PAGE], 10); diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js index b53557c0ec5..ee5e778f63d 100644 --- a/app/assets/javascripts/search/store/actions.js +++ b/app/assets/javascripts/search/store/actions.js @@ -46,38 +46,44 @@ export const fetchProjects = ({ commit, state }, search) => { } }; -export const loadFrequentGroups = async ({ commit }) => { - const data = loadDataFromLS(GROUPS_LOCAL_STORAGE_KEY); - commit(types.LOAD_FREQUENT_ITEMS, { key: GROUPS_LOCAL_STORAGE_KEY, data }); +export const preloadStoredFrequentItems = ({ commit }) => { + const storedGroups = loadDataFromLS(GROUPS_LOCAL_STORAGE_KEY); + commit(types.LOAD_FREQUENT_ITEMS, { key: GROUPS_LOCAL_STORAGE_KEY, data: storedGroups }); - const promises = data.map((d) => Api.group(d.id)); + const storedProjects = loadDataFromLS(PROJECTS_LOCAL_STORAGE_KEY); + commit(types.LOAD_FREQUENT_ITEMS, { key: PROJECTS_LOCAL_STORAGE_KEY, data: storedProjects }); +}; + +export const loadFrequentGroups = async ({ commit, state }) => { + const storedData = state.frequentItems[GROUPS_LOCAL_STORAGE_KEY]; + const promises = storedData.map((d) => Api.group(d.id)); try { - const inflatedData = mergeById(await Promise.all(promises), data); + const inflatedData = mergeById(await Promise.all(promises), storedData); commit(types.LOAD_FREQUENT_ITEMS, { key: GROUPS_LOCAL_STORAGE_KEY, data: inflatedData }); } catch { createFlash({ message: __('There was a problem fetching recent groups.') }); } }; -export const loadFrequentProjects = async ({ commit }) => { - const data = loadDataFromLS(PROJECTS_LOCAL_STORAGE_KEY); - commit(types.LOAD_FREQUENT_ITEMS, { key: PROJECTS_LOCAL_STORAGE_KEY, data }); - - const promises = data.map((d) => Api.project(d.id).then((res) => res.data)); +export const loadFrequentProjects = async ({ commit, state }) => { + const storedData = state.frequentItems[PROJECTS_LOCAL_STORAGE_KEY]; + const promises = storedData.map((d) => Api.project(d.id).then((res) => res.data)); try { - const inflatedData = mergeById(await Promise.all(promises), data); + const inflatedData = mergeById(await Promise.all(promises), storedData); commit(types.LOAD_FREQUENT_ITEMS, { key: PROJECTS_LOCAL_STORAGE_KEY, data: inflatedData }); } catch { createFlash({ message: __('There was a problem fetching recent projects.') }); } }; -export const setFrequentGroup = ({ state }, item) => { - setFrequentItemToLS(GROUPS_LOCAL_STORAGE_KEY, state.frequentItems, item); +export const setFrequentGroup = ({ state, commit }, item) => { + const frequentItems = setFrequentItemToLS(GROUPS_LOCAL_STORAGE_KEY, state.frequentItems, item); + commit(types.LOAD_FREQUENT_ITEMS, { key: GROUPS_LOCAL_STORAGE_KEY, data: frequentItems }); }; -export const setFrequentProject = ({ state }, item) => { - setFrequentItemToLS(PROJECTS_LOCAL_STORAGE_KEY, state.frequentItems, item); +export const setFrequentProject = ({ state, commit }, item) => { + const frequentItems = setFrequentItemToLS(PROJECTS_LOCAL_STORAGE_KEY, state.frequentItems, item); + commit(types.LOAD_FREQUENT_ITEMS, { key: PROJECTS_LOCAL_STORAGE_KEY, data: frequentItems }); }; export const setQuery = ({ commit }, { key, value }) => { diff --git a/app/assets/javascripts/search/store/utils.js b/app/assets/javascripts/search/store/utils.js index 60c09221ca9..b7d97213594 100644 --- a/app/assets/javascripts/search/store/utils.js +++ b/app/assets/javascripts/search/store/utils.js @@ -21,7 +21,7 @@ export const loadDataFromLS = (key) => { export const setFrequentItemToLS = (key, data, itemData) => { if (!AccessorUtilities.isLocalStorageAccessSafe()) { - return; + return []; } const keyList = [ @@ -66,9 +66,11 @@ export const setFrequentItemToLS = (key, data, itemData) => { // Note we do not need to commit a mutation here as immediately after this we refresh the page to // update the search results. localStorage.setItem(key, JSON.stringify(frequentItems)); + return frequentItems; } catch { // The LS got in a bad state, let's wipe it localStorage.removeItem(key); + return []; } }; diff --git a/app/assets/javascripts/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue index a490adbc11a..65114ee066e 100644 --- a/app/assets/javascripts/search/topbar/components/app.vue +++ b/app/assets/javascripts/search/topbar/components/app.vue @@ -39,8 +39,11 @@ export default { return !this.query.snippets || this.query.snippets === 'false'; }, }, + created() { + this.preloadStoredFrequentItems(); + }, methods: { - ...mapActions(['applyQuery', 'setQuery']), + ...mapActions(['applyQuery', 'setQuery', 'preloadStoredFrequentItems']), }, }; </script> diff --git a/app/assets/javascripts/search/topbar/components/group_filter.vue b/app/assets/javascripts/search/topbar/components/group_filter.vue index 45a6ae73fac..e5edb21792a 100644 --- a/app/assets/javascripts/search/topbar/components/group_filter.vue +++ b/app/assets/javascripts/search/topbar/components/group_filter.vue @@ -18,12 +18,18 @@ export default { }, }, computed: { - ...mapState(['groups', 'fetchingGroups']), + ...mapState(['query', 'groups', 'fetchingGroups']), ...mapGetters(['frequentGroups']), selectedGroup() { return isEmpty(this.initialData) ? ANY_OPTION : this.initialData; }, }, + created() { + // This tracks groups searched via the top nav search bar + if (this.query.nav_source === 'navbar' && this.initialData?.id) { + this.setFrequentGroup(this.initialData); + } + }, methods: { ...mapActions(['fetchGroups', 'setFrequentGroup', 'loadFrequentGroups']), handleGroupChange(group) { @@ -33,7 +39,11 @@ export default { } visitUrl( - setUrlParams({ [GROUP_DATA.queryParam]: group.id, [PROJECT_DATA.queryParam]: null }), + setUrlParams({ + [GROUP_DATA.queryParam]: group.id, + [PROJECT_DATA.queryParam]: null, + nav_source: null, + }), ); }, }, diff --git a/app/assets/javascripts/search/topbar/components/project_filter.vue b/app/assets/javascripts/search/topbar/components/project_filter.vue index 1ca31db61e5..85cf2ddbbff 100644 --- a/app/assets/javascripts/search/topbar/components/project_filter.vue +++ b/app/assets/javascripts/search/topbar/components/project_filter.vue @@ -17,12 +17,18 @@ export default { }, }, computed: { - ...mapState(['projects', 'fetchingProjects']), + ...mapState(['query', 'projects', 'fetchingProjects']), ...mapGetters(['frequentProjects']), selectedProject() { return this.initialData ? this.initialData : ANY_OPTION; }, }, + created() { + // This tracks projects searched via the top nav search bar + if (this.query.nav_source === 'navbar' && this.initialData?.id) { + this.setFrequentProject(this.initialData); + } + }, methods: { ...mapActions(['fetchProjects', 'setFrequentProject', 'loadFrequentProjects']), handleProjectChange(project) { @@ -35,6 +41,7 @@ export default { const queryParams = { ...(project.namespace?.id && { [GROUP_DATA.queryParam]: project.namespace.id }), [PROJECT_DATA.queryParam]: project.id, + nav_source: null, }; visitUrl(setUrlParams(queryParams)); diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index 4f278677c5f..b2bf913fe45 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -284,8 +284,8 @@ export class SearchAutocomplete { if (projectId) { const projectOptions = gl.projectOptions[getProjectSlug()]; const url = groupId - ? `${gon.relative_url_root}/search?search=${term}&project_id=${projectId}&group_id=${groupId}` - : `${gon.relative_url_root}/search?search=${term}&project_id=${projectId}`; + ? `${gon.relative_url_root}/search?search=${term}&project_id=${projectId}&group_id=${groupId}&nav_source=navbar` + : `${gon.relative_url_root}/search?search=${term}&project_id=${projectId}&nav_source=navbar`; options.push({ icon, @@ -313,7 +313,7 @@ export class SearchAutocomplete { }, false, ), - url: `${gon.relative_url_root}/search?search=${term}&group_id=${groupId}`, + url: `${gon.relative_url_root}/search?search=${term}&group_id=${groupId}&nav_source=navbar`, }); } @@ -321,7 +321,7 @@ export class SearchAutocomplete { icon, text: term, template: s__('SearchAutocomplete|in all GitLab'), - url: `${gon.relative_url_root}/search?search=${term}`, + url: `${gon.relative_url_root}/search?search=${term}&nav_source=navbar`, }); return options; diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue index 513a7353d28..6c70a8c33db 100644 --- a/app/assets/javascripts/security_configuration/components/app.vue +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -1,23 +1,216 @@ <script> -import ConfigurationTable from './configuration_table.vue'; +import { GlTab, GlTabs, GlSprintf, GlLink } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; +import AutoDevOpsAlert from './auto_dev_ops_alert.vue'; +import AutoDevOpsEnabledAlert from './auto_dev_ops_enabled_alert.vue'; +import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY } from './constants'; +import FeatureCard from './feature_card.vue'; +import SectionLayout from './section_layout.vue'; +import UpgradeBanner from './upgrade_banner.vue'; + +export const i18n = { + compliance: s__('SecurityConfiguration|Compliance'), + configurationHistory: s__('SecurityConfiguration|Configuration history'), + securityTesting: s__('SecurityConfiguration|Security testing'), + latestPipelineDescription: s__( + `SecurityConfiguration|The status of the tools only applies to the + default branch and is based on the %{linkStart}latest pipeline%{linkEnd}.`, + ), + description: s__( + `SecurityConfiguration|Once you've enabled a scan for the default branch, + any subsequent feature branch you create will include the scan.`, + ), + securityConfiguration: __('Security Configuration'), +}; export default { + i18n, components: { - ConfigurationTable, + AutoDevOpsAlert, + AutoDevOpsEnabledAlert, + FeatureCard, + GlLink, + GlSprintf, + GlTab, + GlTabs, + LocalStorageSync, + SectionLayout, + UpgradeBanner, + UserCalloutDismisser, + }, + inject: ['projectPath'], + props: { + augmentedSecurityFeatures: { + type: Array, + required: true, + }, + augmentedComplianceFeatures: { + type: Array, + required: true, + }, + gitlabCiPresent: { + type: Boolean, + required: false, + default: false, + }, + autoDevopsEnabled: { + type: Boolean, + required: false, + default: false, + }, + canEnableAutoDevops: { + type: Boolean, + required: false, + default: false, + }, + gitlabCiHistoryPath: { + type: String, + required: false, + default: '', + }, + latestPipelinePath: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + autoDevopsEnabledAlertDismissedProjects: [], + }; + }, + computed: { + canUpgrade() { + return [...this.augmentedSecurityFeatures, ...this.augmentedComplianceFeatures].some( + ({ available }) => !available, + ); + }, + canViewCiHistory() { + return Boolean(this.gitlabCiPresent && this.gitlabCiHistoryPath); + }, + shouldShowDevopsAlert() { + return !this.autoDevopsEnabled && !this.gitlabCiPresent && this.canEnableAutoDevops; + }, + shouldShowAutoDevopsEnabledAlert() { + return ( + this.autoDevopsEnabled && + !this.autoDevopsEnabledAlertDismissedProjects.includes(this.projectPath) + ); + }, + }, + methods: { + dismissAutoDevopsEnabledAlert() { + const dismissedProjects = new Set(this.autoDevopsEnabledAlertDismissedProjects); + dismissedProjects.add(this.projectPath); + this.autoDevopsEnabledAlertDismissedProjects = Array.from(dismissedProjects); + }, }, + autoDevopsEnabledAlertStorageKey: AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY, }; </script> <template> <article> + <local-storage-sync + v-model="autoDevopsEnabledAlertDismissedProjects" + :storage-key="$options.autoDevopsEnabledAlertStorageKey" + as-json + /> + + <user-callout-dismisser + v-if="shouldShowDevopsAlert" + feature-name="security_configuration_devops_alert" + > + <template #default="{ dismiss, shouldShowCallout }"> + <auto-dev-ops-alert v-if="shouldShowCallout" class="gl-mt-3" @dismiss="dismiss" /> + </template> + </user-callout-dismisser> <header> - <h4 class="gl-my-5"> - {{ __('Security Configuration') }} - </h4> - <h5 class="gl-font-lg gl-mt-7"> - {{ s__('SecurityConfiguration|Testing & Compliance') }} - </h5> + <h1 class="gl-font-size-h1">{{ $options.i18n.securityConfiguration }}</h1> </header> - <configuration-table /> + <user-callout-dismisser v-if="canUpgrade" feature-name="security_configuration_upgrade_banner"> + <template #default="{ dismiss, shouldShowCallout }"> + <upgrade-banner v-if="shouldShowCallout" @close="dismiss" /> + </template> + </user-callout-dismisser> + + <gl-tabs content-class="gl-pt-0"> + <gl-tab data-testid="security-testing-tab" :title="$options.i18n.securityTesting"> + <auto-dev-ops-enabled-alert + v-if="shouldShowAutoDevopsEnabledAlert" + class="gl-mt-3" + @dismiss="dismissAutoDevopsEnabledAlert" + /> + + <section-layout :heading="$options.i18n.securityTesting"> + <template #description> + <p> + <span data-testid="latest-pipeline-info-security"> + <gl-sprintf + v-if="latestPipelinePath" + :message="$options.i18n.latestPipelineDescription" + > + <template #link="{ content }"> + <gl-link :href="latestPipelinePath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> + + {{ $options.i18n.description }} + </p> + <p v-if="canViewCiHistory"> + <gl-link data-testid="security-view-history-link" :href="gitlabCiHistoryPath">{{ + $options.i18n.configurationHistory + }}</gl-link> + </p> + </template> + + <template #features> + <feature-card + v-for="feature in augmentedSecurityFeatures" + :key="feature.type" + data-testid="security-testing-card" + :feature="feature" + class="gl-mb-6" + /> + </template> + </section-layout> + </gl-tab> + <gl-tab data-testid="compliance-testing-tab" :title="$options.i18n.compliance"> + <section-layout :heading="$options.i18n.compliance"> + <template #description> + <p> + <span data-testid="latest-pipeline-info-compliance"> + <gl-sprintf + v-if="latestPipelinePath" + :message="$options.i18n.latestPipelineDescription" + > + <template #link="{ content }"> + <gl-link :href="latestPipelinePath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> + + {{ $options.i18n.description }} + </p> + <p v-if="canViewCiHistory"> + <gl-link data-testid="compliance-view-history-link" :href="gitlabCiHistoryPath">{{ + $options.i18n.configurationHistory + }}</gl-link> + </p> + </template> + <template #features> + <feature-card + v-for="feature in augmentedComplianceFeatures" + :key="feature.type" + :feature="feature" + class="gl-mb-6" + /> + </template> + </section-layout> + </gl-tab> + </gl-tabs> </article> </template> diff --git a/app/assets/javascripts/security_configuration/components/auto_dev_ops_enabled_alert.vue b/app/assets/javascripts/security_configuration/components/auto_dev_ops_enabled_alert.vue new file mode 100644 index 00000000000..7192108f7c5 --- /dev/null +++ b/app/assets/javascripts/security_configuration/components/auto_dev_ops_enabled_alert.vue @@ -0,0 +1,30 @@ +<script> +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlAlert, + GlLink, + GlSprintf, + }, + inject: ['autoDevopsHelpPagePath'], + i18n: { + body: s__( + 'AutoDevopsAlert|Security testing tools enabled with %{linkStart}Auto DevOps%{linkEnd}', + ), + }, +}; +</script> + +<template> + <gl-alert variant="success" @dismiss="$emit('dismiss')"> + <gl-sprintf :message="$options.i18n.body"> + <template #link="{ content }"> + <gl-link :href="autoDevopsHelpPagePath"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-alert> +</template> diff --git a/app/assets/javascripts/security_configuration/components/configuration_table.vue b/app/assets/javascripts/security_configuration/components/configuration_table.vue deleted file mode 100644 index 7f250bf1365..00000000000 --- a/app/assets/javascripts/security_configuration/components/configuration_table.vue +++ /dev/null @@ -1,109 +0,0 @@ -<script> -import { GlLink, GlTable, GlAlert } from '@gitlab/ui'; -import { s__, sprintf } from '~/locale'; -import ManageViaMR from '~/vue_shared/security_configuration/components/manage_via_mr.vue'; -import { - REPORT_TYPE_SAST, - REPORT_TYPE_DAST, - REPORT_TYPE_DAST_PROFILES, - REPORT_TYPE_DEPENDENCY_SCANNING, - REPORT_TYPE_CONTAINER_SCANNING, - REPORT_TYPE_CLUSTER_IMAGE_SCANNING, - REPORT_TYPE_COVERAGE_FUZZING, - REPORT_TYPE_API_FUZZING, - REPORT_TYPE_LICENSE_COMPLIANCE, -} from '~/vue_shared/security_reports/constants'; - -import { scanners } from './constants'; -import Upgrade from './upgrade.vue'; - -const borderClasses = 'gl-border-b-1! gl-border-b-solid! gl-border-gray-100!'; -const thClass = `gl-text-gray-900 gl-bg-transparent! ${borderClasses}`; - -export default { - components: { - GlLink, - GlTable, - GlAlert, - }, - data() { - return { - errorMessage: '', - }; - }, - methods: { - getFeatureDocumentationLinkLabel(item) { - return sprintf(s__('SecurityConfiguration|Feature documentation for %{featureName}'), { - featureName: item.name, - }); - }, - onError(value) { - this.errorMessage = value; - }, - getComponentForItem(item) { - const COMPONENTS = { - [REPORT_TYPE_SAST]: ManageViaMR, - [REPORT_TYPE_DAST]: Upgrade, - [REPORT_TYPE_DAST_PROFILES]: Upgrade, - [REPORT_TYPE_DEPENDENCY_SCANNING]: Upgrade, - [REPORT_TYPE_CONTAINER_SCANNING]: Upgrade, - [REPORT_TYPE_CLUSTER_IMAGE_SCANNING]: Upgrade, - [REPORT_TYPE_COVERAGE_FUZZING]: Upgrade, - [REPORT_TYPE_API_FUZZING]: Upgrade, - [REPORT_TYPE_LICENSE_COMPLIANCE]: Upgrade, - }; - return COMPONENTS[item.type]; - }, - }, - table: { - fields: [ - { - key: 'feature', - label: s__('SecurityConfiguration|Security Control'), - thClass, - }, - { - key: 'manage', - label: s__('SecurityConfiguration|Manage'), - thClass, - }, - ], - items: scanners, - }, -}; -</script> - -<template> - <div> - <gl-alert v-if="errorMessage" variant="danger" :dismissible="false"> - {{ errorMessage }} - </gl-alert> - <gl-table :items="$options.table.items" :fields="$options.table.fields" stacked="md"> - <template #cell(feature)="{ item }"> - <div class="gl-text-gray-900"> - {{ item.name }} - </div> - <div> - {{ item.description }} - <gl-link - target="_blank" - data-testid="help-link" - :href="item.helpPath" - :aria-label="getFeatureDocumentationLinkLabel(item)" - > - {{ s__('SecurityConfiguration|More information') }} - </gl-link> - </div> - </template> - - <template #cell(manage)="{ item }"> - <component - :is="getComponentForItem(item)" - :feature="item" - :data-testid="item.type" - @error="onError" - /> - </template> - </gl-table> - </div> -</template> diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 5cb9277040d..ebe0138f046 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -18,8 +18,9 @@ import configureSastMutation from '../graphql/configure_sast.mutation.graphql'; import configureSecretDetectionMutation from '../graphql/configure_secret_detection.mutation.graphql'; /** - * Translations & helpPagePaths for Static Security Configuration Page + * Translations & helpPagePaths for Security Configuration Page */ + export const SAST_NAME = __('Static Application Security Testing (SAST)'); export const SAST_SHORT_NAME = s__('ciReport|SAST'); export const SAST_DESCRIPTION = __('Analyze your source code for known vulnerabilities.'); @@ -98,6 +99,10 @@ export const COVERAGE_FUZZING_DESCRIPTION = __( export const COVERAGE_FUZZING_HELP_PATH = helpPagePath( 'user/application_security/coverage_fuzzing/index', ); +export const COVERAGE_FUZZING_CONFIG_HELP_PATH = helpPagePath( + 'user/application_security/coverage_fuzzing/index', + { anchor: 'configuration' }, +); export const API_FUZZING_NAME = __('API Fuzzing'); export const API_FUZZING_DESCRIPTION = __('Find bugs in your code with API fuzzing.'); @@ -111,73 +116,6 @@ export const LICENSE_COMPLIANCE_HELP_PATH = helpPagePath( 'user/compliance/license_compliance/index', ); -export const UPGRADE_CTA = s__( - 'SecurityConfiguration|Available with %{linkStart}upgrade or free trial%{linkEnd}', -); - -export const scanners = [ - { - name: SAST_NAME, - description: SAST_DESCRIPTION, - helpPath: SAST_HELP_PATH, - type: REPORT_TYPE_SAST, - }, - { - name: DAST_NAME, - description: DAST_DESCRIPTION, - helpPath: DAST_HELP_PATH, - type: REPORT_TYPE_DAST, - }, - { - name: DAST_PROFILES_NAME, - description: DAST_PROFILES_DESCRIPTION, - helpPath: DAST_PROFILES_HELP_PATH, - type: REPORT_TYPE_DAST_PROFILES, - }, - { - name: DEPENDENCY_SCANNING_NAME, - description: DEPENDENCY_SCANNING_DESCRIPTION, - helpPath: DEPENDENCY_SCANNING_HELP_PATH, - type: REPORT_TYPE_DEPENDENCY_SCANNING, - }, - { - name: CONTAINER_SCANNING_NAME, - description: CONTAINER_SCANNING_DESCRIPTION, - helpPath: CONTAINER_SCANNING_HELP_PATH, - type: REPORT_TYPE_CONTAINER_SCANNING, - }, - { - name: CLUSTER_IMAGE_SCANNING_NAME, - description: CLUSTER_IMAGE_SCANNING_DESCRIPTION, - helpPath: CLUSTER_IMAGE_SCANNING_HELP_PATH, - type: REPORT_TYPE_CLUSTER_IMAGE_SCANNING, - }, - { - name: SECRET_DETECTION_NAME, - description: SECRET_DETECTION_DESCRIPTION, - helpPath: SECRET_DETECTION_HELP_PATH, - type: REPORT_TYPE_SECRET_DETECTION, - }, - { - name: COVERAGE_FUZZING_NAME, - description: COVERAGE_FUZZING_DESCRIPTION, - helpPath: COVERAGE_FUZZING_HELP_PATH, - type: REPORT_TYPE_COVERAGE_FUZZING, - }, - { - name: API_FUZZING_NAME, - description: API_FUZZING_DESCRIPTION, - helpPath: API_FUZZING_HELP_PATH, - type: REPORT_TYPE_API_FUZZING, - }, - { - name: LICENSE_COMPLIANCE_NAME, - description: LICENSE_COMPLIANCE_DESCRIPTION, - helpPath: LICENSE_COMPLIANCE_HELP_PATH, - type: REPORT_TYPE_LICENSE_COMPLIANCE, - }, -]; - export const securityFeatures = [ { name: SAST_NAME, @@ -219,7 +157,7 @@ export const securityFeatures = [ // This field will eventually come from the backend, the progress is // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/331621 - canEnableByMergeRequest: window.gon.features?.secDependencyScanningUiEnable, + canEnableByMergeRequest: true, }, { name: CONTAINER_SCANNING_NAME, @@ -262,6 +200,7 @@ export const securityFeatures = [ name: COVERAGE_FUZZING_NAME, description: COVERAGE_FUZZING_DESCRIPTION, helpPath: COVERAGE_FUZZING_HELP_PATH, + configurationHelpPath: COVERAGE_FUZZING_CONFIG_HELP_PATH, type: REPORT_TYPE_COVERAGE_FUZZING, }, ]; @@ -300,3 +239,6 @@ export const featureToMutationMap = { }), }, }; + +export const AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY = + 'security_configuration_auto_devops_enabled_dismissed_projects'; diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue index 23cffde1f83..0ecfdf420db 100644 --- a/app/assets/javascripts/security_configuration/components/feature_card.vue +++ b/app/assets/javascripts/security_configuration/components/feature_card.vue @@ -83,7 +83,11 @@ export default { <div class="gl-display-flex gl-align-items-baseline"> <h3 class="gl-font-lg gl-m-0 gl-mr-3">{{ feature.name }}</h3> - <div :class="statusClasses" data-testid="feature-status"> + <div + :class="statusClasses" + data-testid="feature-status" + :data-qa-selector="`${feature.type}_status`" + > <template v-if="hasStatus"> <template v-if="enabled"> <gl-icon name="check-circle-filled" /> @@ -112,6 +116,7 @@ export default { :href="feature.configurationPath" variant="confirm" :category="configurationButton.category" + :data-qa-selector="`${feature.type}_enable_button`" class="gl-mt-5" > {{ configurationButton.text }} @@ -125,7 +130,12 @@ export default { class="gl-mt-5" /> - <gl-button v-else icon="external-link" :href="feature.configurationHelpPath" class="gl-mt-5"> + <gl-button + v-else-if="feature.configurationHelpPath" + icon="external-link" + :href="feature.configurationHelpPath" + class="gl-mt-5" + > {{ $options.i18n.configurationGuide }} </gl-button> </template> diff --git a/app/assets/javascripts/security_configuration/components/redesigned_app.vue b/app/assets/javascripts/security_configuration/components/redesigned_app.vue deleted file mode 100644 index 915da378a4f..00000000000 --- a/app/assets/javascripts/security_configuration/components/redesigned_app.vue +++ /dev/null @@ -1,179 +0,0 @@ -<script> -import { GlTab, GlTabs, GlSprintf, GlLink } from '@gitlab/ui'; -import { __, s__ } from '~/locale'; -import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; -import AutoDevOpsAlert from './auto_dev_ops_alert.vue'; -import FeatureCard from './feature_card.vue'; -import SectionLayout from './section_layout.vue'; -import UpgradeBanner from './upgrade_banner.vue'; - -export const i18n = { - compliance: s__('SecurityConfiguration|Compliance'), - configurationHistory: s__('SecurityConfiguration|Configuration history'), - securityTesting: s__('SecurityConfiguration|Security testing'), - latestPipelineDescription: s__( - `SecurityConfiguration|The status of the tools only applies to the - default branch and is based on the %{linkStart}latest pipeline%{linkEnd}.`, - ), - description: s__( - `SecurityConfiguration|Once you've enabled a scan for the default branch, - any subsequent feature branch you create will include the scan.`, - ), - securityConfiguration: __('Security Configuration'), -}; - -export default { - i18n, - components: { - GlTab, - GlLink, - GlTabs, - GlSprintf, - FeatureCard, - SectionLayout, - UpgradeBanner, - AutoDevOpsAlert, - UserCalloutDismisser, - }, - props: { - augmentedSecurityFeatures: { - type: Array, - required: true, - }, - augmentedComplianceFeatures: { - type: Array, - required: true, - }, - gitlabCiPresent: { - type: Boolean, - required: false, - default: false, - }, - autoDevopsEnabled: { - type: Boolean, - required: false, - default: false, - }, - canEnableAutoDevops: { - type: Boolean, - required: false, - default: false, - }, - gitlabCiHistoryPath: { - type: String, - required: false, - default: '', - }, - latestPipelinePath: { - type: String, - required: false, - default: '', - }, - }, - computed: { - canUpgrade() { - return [...this.augmentedSecurityFeatures, ...this.augmentedComplianceFeatures].some( - ({ available }) => !available, - ); - }, - canViewCiHistory() { - return Boolean(this.gitlabCiPresent && this.gitlabCiHistoryPath); - }, - shouldShowDevopsAlert() { - return !this.autoDevopsEnabled && !this.gitlabCiPresent && this.canEnableAutoDevops; - }, - }, -}; -</script> - -<template> - <article> - <user-callout-dismisser - v-if="shouldShowDevopsAlert" - feature-name="security_configuration_devops_alert" - > - <template #default="{ dismiss, shouldShowCallout }"> - <auto-dev-ops-alert v-if="shouldShowCallout" class="gl-mt-3" @dismiss="dismiss" /> - </template> - </user-callout-dismisser> - <header> - <h1 class="gl-font-size-h1">{{ $options.i18n.securityConfiguration }}</h1> - </header> - <user-callout-dismisser v-if="canUpgrade" feature-name="security_configuration_upgrade_banner"> - <template #default="{ dismiss, shouldShowCallout }"> - <upgrade-banner v-if="shouldShowCallout" @close="dismiss" /> - </template> - </user-callout-dismisser> - - <gl-tabs content-class="gl-pt-6"> - <gl-tab data-testid="security-testing-tab" :title="$options.i18n.securityTesting"> - <section-layout :heading="$options.i18n.securityTesting"> - <template #description> - <p> - <span data-testid="latest-pipeline-info-security"> - <gl-sprintf - v-if="latestPipelinePath" - :message="$options.i18n.latestPipelineDescription" - > - <template #link="{ content }"> - <gl-link :href="latestPipelinePath">{{ content }}</gl-link> - </template> - </gl-sprintf> - </span> - - {{ $options.i18n.description }} - </p> - <p v-if="canViewCiHistory"> - <gl-link data-testid="security-view-history-link" :href="gitlabCiHistoryPath">{{ - $options.i18n.configurationHistory - }}</gl-link> - </p> - </template> - - <template #features> - <feature-card - v-for="feature in augmentedSecurityFeatures" - :key="feature.type" - data-testid="security-testing-card" - :feature="feature" - class="gl-mb-6" - /> - </template> - </section-layout> - </gl-tab> - <gl-tab data-testid="compliance-testing-tab" :title="$options.i18n.compliance"> - <section-layout :heading="$options.i18n.compliance"> - <template #description> - <p> - <span data-testid="latest-pipeline-info-compliance"> - <gl-sprintf - v-if="latestPipelinePath" - :message="$options.i18n.latestPipelineDescription" - > - <template #link="{ content }"> - <gl-link :href="latestPipelinePath">{{ content }}</gl-link> - </template> - </gl-sprintf> - </span> - - {{ $options.i18n.description }} - </p> - <p v-if="canViewCiHistory"> - <gl-link data-testid="compliance-view-history-link" :href="gitlabCiHistoryPath">{{ - $options.i18n.configurationHistory - }}</gl-link> - </p> - </template> - <template #features> - <feature-card - v-for="feature in augmentedComplianceFeatures" - :key="feature.type" - :feature="feature" - class="gl-mb-6" - /> - </template> - </section-layout> - </gl-tab> - </gl-tabs> - </article> -</template> diff --git a/app/assets/javascripts/security_configuration/components/section_layout.vue b/app/assets/javascripts/security_configuration/components/section_layout.vue index e351f9b9d8d..1fe8dd862a0 100644 --- a/app/assets/javascripts/security_configuration/components/section_layout.vue +++ b/app/assets/javascripts/security_configuration/components/section_layout.vue @@ -11,7 +11,7 @@ export default { </script> <template> - <div class="row gl-line-height-20"> + <div class="row gl-line-height-20 gl-pt-6"> <div class="col-lg-4"> <h2 class="gl-font-size-h2 gl-mt-0">{{ heading }}</h2> <slot name="description"></slot> diff --git a/app/assets/javascripts/security_configuration/components/upgrade.vue b/app/assets/javascripts/security_configuration/components/upgrade.vue deleted file mode 100644 index 2541c29224a..00000000000 --- a/app/assets/javascripts/security_configuration/components/upgrade.vue +++ /dev/null @@ -1,32 +0,0 @@ -<script> -import { GlLink, GlSprintf } from '@gitlab/ui'; -import { UPGRADE_CTA } from './constants'; - -export default { - components: { - GlLink, - GlSprintf, - }, - inject: { - upgradePath: { - from: 'upgradePath', - default: '#', - }, - }, - i18n: { - UPGRADE_CTA, - }, -}; -</script> - -<template> - <span> - <gl-sprintf :message="$options.i18n.UPGRADE_CTA"> - <template #link="{ content }"> - <gl-link target="_blank" :href="upgradePath"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </span> -</template> diff --git a/app/assets/javascripts/security_configuration/components/upgrade_banner.vue b/app/assets/javascripts/security_configuration/components/upgrade_banner.vue index ca0f9e5c85a..79e6b9d7a23 100644 --- a/app/assets/javascripts/security_configuration/components/upgrade_banner.vue +++ b/app/assets/javascripts/security_configuration/components/upgrade_banner.vue @@ -8,20 +8,18 @@ export default { }, inject: ['upgradePath'], i18n: { - title: s__('SecurityConfiguration|Secure your project with Ultimate'), + title: s__('SecurityConfiguration|Secure your project'), bodyStart: s__( - `SecurityConfiguration|GitLab Ultimate checks your application for security vulnerabilities - that may lead to unauthorized access, data leaks, and denial of service - attacks. Its features include:`, + `SecurityConfiguration|Immediately begin risk analysis and remediation with application security features. Start with SAST and Secret Detection, available to all plans. Upgrade to Ultimate to get all features, including:`, ), bodyListItems: [ - s__('SecurityConfiguration|Vulnerability details and statistics in the merge request.'), - s__('SecurityConfiguration|High-level vulnerability statistics across projects and groups.'), - s__('SecurityConfiguration|Runtime security metrics for application environments.'), + s__('SecurityConfiguration|Vulnerability details and statistics in the merge request'), + s__('SecurityConfiguration|High-level vulnerability statistics across projects and groups'), + s__('SecurityConfiguration|Runtime security metrics for application environments'), + s__( + 'SecurityConfiguration|More scan types, including Container Scanning, DAST, Dependency Scanning, Fuzzing, and Licence Compliance', + ), ], - bodyEnd: s__( - 'SecurityConfiguration|With the information provided, you can immediately begin risk analysis and remediation within GitLab.', - ), buttonText: s__('SecurityConfiguration|Upgrade or start a free trial'), }, }; @@ -32,14 +30,14 @@ export default { :title="$options.i18n.title" :button-text="$options.i18n.buttonText" :button-link="upgradePath" + variant="introduction" v-on="$listeners" > <p>{{ $options.i18n.bodyStart }}</p> - <ul> + <ul class="gl-pl-6"> <li v-for="bodyListItem in $options.i18n.bodyListItems" :key="bodyListItem"> {{ bodyListItem }} </li> </ul> - <p>{{ $options.i18n.bodyEnd }}</p> </gl-banner> </template> diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js index f05bd79258e..60d2c0d4e5a 100644 --- a/app/assets/javascripts/security_configuration/index.js +++ b/app/assets/javascripts/security_configuration/index.js @@ -4,14 +4,17 @@ import createDefaultClient from '~/lib/graphql'; import { parseBooleanDataAttributes } from '~/lib/utils/dom_utils'; import SecurityConfigurationApp from './components/app.vue'; import { securityFeatures, complianceFeatures } from './components/constants'; -import RedesignedSecurityConfigurationApp from './components/redesigned_app.vue'; import { augmentFeatures } from './utils'; -export const initRedesignedSecurityConfiguration = (el) => { +export const initSecurityConfiguration = (el) => { + if (!el) { + return null; + } + Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), }); const { @@ -40,7 +43,7 @@ export const initRedesignedSecurityConfiguration = (el) => { autoDevopsPath, }, render(createElement) { - return createElement(RedesignedSecurityConfigurationApp, { + return createElement(SecurityConfigurationApp, { props: { augmentedComplianceFeatures, augmentedSecurityFeatures, @@ -56,33 +59,3 @@ export const initRedesignedSecurityConfiguration = (el) => { }, }); }; - -export const initCESecurityConfiguration = (el) => { - if (!el) { - return null; - } - - if (gon.features?.securityConfigurationRedesign) { - return initRedesignedSecurityConfiguration(el); - } - - Vue.use(VueApollo); - - const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), - }); - - const { projectPath, upgradePath } = el.dataset; - - return new Vue({ - el, - apolloProvider, - provide: { - projectPath, - upgradePath, - }, - render(createElement) { - return createElement(SecurityConfigurationApp); - }, - }); -}; diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue index 4c1f0d892af..2f31d8ef3fb 100644 --- a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue +++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue @@ -1,8 +1,16 @@ <script> -/* eslint-disable vue/no-v-html */ -import { GlFormGroup, GlButton, GlModal, GlToast, GlToggle } from '@gitlab/ui'; +import { + GlFormGroup, + GlButton, + GlModal, + GlToast, + GlToggle, + GlLink, + GlSafeHtmlDirective, +} from '@gitlab/ui'; import Vue from 'vue'; import { mapState, mapActions } from 'vuex'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { visitUrl, getBaseURL } from '~/lib/utils/url_utility'; import { __, s__, sprintf } from '~/locale'; @@ -15,9 +23,13 @@ export default { GlButton, GlModal, GlToggle, + GlLink, + }, + directives: { + SafeHtml: GlSafeHtmlDirective, }, formLabels: { - createProject: __('Create Project'), + createProject: __('Self monitoring'), }, data() { return { @@ -48,7 +60,7 @@ export default { if (this.projectCreated) { return sprintf( s__( - 'SelfMonitoring|Enabling this feature creates a %{projectLinkStart}project%{projectLinkEnd} that can be used to monitor the health of your instance.', + 'SelfMonitoring|Self monitoring is active. Use the %{projectLinkStart}self monitoring project%{projectLinkEnd} to monitor the health of your instance.', ), { projectLinkStart: `<a href="${this.selfMonitorProjectFullUrl}">`, @@ -59,9 +71,12 @@ export default { } return s__( - 'SelfMonitoring|Enabling this feature creates a project that can be used to monitor the health of your instance.', + 'SelfMonitoring|Activate self monitoring to create a project to use to monitor the health of your instance.', ); }, + helpDocsPath() { + return helpPagePath('administration/monitoring/gitlab_self_monitoring_project/index'); + }, }, watch: { selfMonitorEnabled() { @@ -126,12 +141,13 @@ export default { </h4> <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> <p class="js-section-sub-header"> - {{ s__('SelfMonitoring|Enable or disable instance self monitoring') }} + {{ s__('SelfMonitoring|Activate or deactivate instance self monitoring.') }} + <gl-link :href="helpDocsPath">{{ __('Learn more.') }}</gl-link> </p> </div> <div class="settings-content"> <form name="self-monitoring-form"> - <p ref="selfMonitoringFormText" v-html="selfMonitoringFormText"></p> + <p ref="selfMonitoringFormText" v-safe-html="selfMonitoringFormText"></p> <gl-form-group> <gl-toggle v-model="selfMonitorEnabled" @@ -142,9 +158,9 @@ export default { </form> </div> <gl-modal - :title="s__('SelfMonitoring|Disable self monitoring?')" + :title="s__('SelfMonitoring|Deactivate self monitoring?')" :modal-id="modalId" - :ok-title="__('Delete project')" + :ok-title="__('Delete self monitoring project')" :cancel-title="__('Cancel')" ok-variant="danger" category="primary" @@ -154,7 +170,7 @@ export default { <div> {{ s__( - 'SelfMonitoring|Disabling this feature will delete the self monitoring project. Are you sure you want to delete the project?', + 'SelfMonitoring|Deactivating self monitoring deletes the self monitoring project. Are you sure you want to deactivate self monitoring and delete the project?', ) }} </div> diff --git a/app/assets/javascripts/self_monitor/store/actions.js b/app/assets/javascripts/self_monitor/store/actions.js index 670ee547b02..f37b654b00a 100644 --- a/app/assets/javascripts/self_monitor/store/actions.js +++ b/app/assets/javascripts/self_monitor/store/actions.js @@ -56,7 +56,7 @@ export const requestCreateProjectSuccess = ({ commit, dispatch }, selfMonitorDat commit(types.SET_LOADING, false); commit(types.SET_PROJECT_URL, selfMonitorData.project_full_path); commit(types.SET_ALERT_CONTENT, { - message: s__('SelfMonitoring|Self monitoring project has been successfully created.'), + message: s__('SelfMonitoring|Self monitoring project successfully created.'), actionText: __('View project'), actionName: 'viewSelfMonitorProject', }); @@ -108,7 +108,7 @@ export const requestDeleteProjectSuccess = ({ commit }) => { commit(types.SET_PROJECT_URL, ''); commit(types.SET_PROJECT_CREATED, false); commit(types.SET_ALERT_CONTENT, { - message: s__('SelfMonitoring|Self monitoring project has been successfully deleted.'), + message: s__('SelfMonitoring|Self monitoring project successfully deleted.'), actionText: __('Undo'), actionName: 'createProject', }); diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue index 98fc0b0a783..2a237e7ace0 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue @@ -1,6 +1,6 @@ <script> import { GlTooltipDirective, GlLink } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; +import { __ } from '~/locale'; import { isUserBusy } from '~/set_status_modal/utils'; import AssigneeAvatar from './assignee_avatar.vue'; @@ -32,10 +32,9 @@ const generateAssigneeTooltip = ({ } if (tooltipHasName && statusInformation.length) { - return sprintf(__('%{name} %{status}'), { - name, - status: statusInformation.map(paranthesize).join(' '), - }); + const status = statusInformation.map(paranthesize).join(' '); + + return `${name} ${status}`; } return name; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue index 4b3b22f6db3..d9c5edc91f1 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue @@ -50,7 +50,7 @@ export default { <gl-loading-icon v-if="loading" size="sm" inline class="align-bottom" /> <a v-if="editable" - class="js-sidebar-dropdown-toggle edit-link float-right" + class="js-sidebar-dropdown-toggle edit-link btn gl-text-gray-900! gl-ml-auto hide-collapsed btn-default btn-sm gl-button btn-default-tertiary float-right" href="#" data-test-id="edit-link" data-track-event="click_edit_button" diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue index b7832ca679c..55179947756 100644 --- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue +++ b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue @@ -55,12 +55,13 @@ export default { }, getUpdateVariables(dropdownLabels) { const currentLabelIds = this.selectedLabels.map((label) => label.id); - const userAddedLabelIds = dropdownLabels - .filter((label) => label.set) - .map((label) => label.id); - const userRemovedLabelIds = dropdownLabels - .filter((label) => !label.set) - .map((label) => label.id); + const dropdownLabelIds = dropdownLabels.map((label) => label.id); + const userAddedLabelIds = this.glFeatures.labelsWidget + ? difference(dropdownLabelIds, currentLabelIds) + : dropdownLabels.filter((label) => label.set).map((label) => label.id); + const userRemovedLabelIds = this.glFeatures.labelsWidget + ? difference(currentLabelIds, dropdownLabelIds) + : dropdownLabels.filter((label) => !label.set).map((label) => label.id); const labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds); @@ -155,7 +156,7 @@ export default { :labels-manage-path="labelsManagePath" :labels-select-in-progress="isLabelsSelectInProgress" :selected-labels="selectedLabels" - :variant="$options.sidebar" + :variant="$options.variant" data-qa-selector="labels_block" @onDropdownClose="handleDropdownClose" @onLabelRemove="handleLabelRemove" diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue index 81ee0a73739..19543d0927a 100644 --- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue @@ -90,7 +90,7 @@ export default { {{ sprintf(__('Lock %{issuableDisplayName}'), { issuableDisplayName: issuableDisplayName }) }} <a v-if="isEditable" - class="float-right lock-edit" + class="float-right lock-edit btn gl-text-gray-900! gl-ml-auto hide-collapsed btn-default btn-sm gl-button btn-default-tertiary gl-mr-n2" href="#" data-testid="edit-link" data-track-event="click_edit_button" diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index 650aa603f18..ad4bfe5b665 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -33,6 +33,11 @@ export default { required: false, default: true, }, + lazy: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -95,7 +100,7 @@ export default { <gl-loading-icon v-if="loading" size="sm" /> <span v-else data-testid="collapsed-count"> {{ participantCount }} </span> </div> - <div v-if="showParticipantLabel" class="title hide-collapsed gl-mb-2"> + <div v-if="showParticipantLabel" class="title hide-collapsed gl-mb-2 gl-line-height-20"> <gl-loading-icon v-if="loading" size="sm" :inline="true" /> {{ participantLabel }} </div> @@ -107,10 +112,11 @@ export default { > <a :href="participant.web_url || participant.webUrl" class="author-link"> <user-avatar-image - :lazy="true" + :lazy="lazy" :img-src="participant.avatar_url || participant.avatarUrl" :size="24" :tooltip-text="participant.name" + :img-alt="participant.name" css-classes="avatar-inline" tooltip-placement="bottom" /> diff --git a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue index 9927a0f9114..39f72b251c7 100644 --- a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue +++ b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue @@ -64,6 +64,7 @@ export default { :loading="isLoading" :participants="participants" :number-of-less-participants="7" + :lazy="false" class="block participants" /> </template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue index 295027186cc..1243603805a 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue @@ -38,7 +38,7 @@ export default { <gl-loading-icon v-if="loading" size="sm" inline class="align-bottom" /> <a v-if="editable" - class="js-sidebar-dropdown-toggle edit-link float-right" + class="js-sidebar-dropdown-toggle edit-link btn gl-text-gray-900! gl-ml-auto hide-collapsed btn-default btn-sm gl-button btn-default-tertiary float-right" href="#" data-track-event="click_edit_button" data-track-label="right_sidebar" diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue index fdf63c23552..5dc93476120 100644 --- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue +++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue @@ -23,6 +23,7 @@ export default { GlLink, SeverityToken, }, + inject: ['canUpdate'], props: { projectPath: { type: String, @@ -153,6 +154,7 @@ export default { > {{ $options.i18n.SEVERITY }} <gl-link + v-if="canUpdate" data-testid="editButton" href="#" @click="toggleFormDropdown" diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index 2e00a23de7c..8ccc0102c3d 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -13,6 +13,7 @@ import { import createFlash from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { IssuableType } from '~/issue_show/constants'; +import { timeFor } from '~/lib/utils/datetime_utility'; import { __, s__, sprintf } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import { @@ -22,6 +23,7 @@ import { issuableAttributesQueries, noAttributeId, defaultEpicSort, + epicIidPattern, } from '~/sidebar/constants'; export default { @@ -118,17 +120,37 @@ export default { return query; }, skip() { + if (this.isEpic && this.searchTerm.startsWith('&') && this.searchTerm.length < 2) { + return true; + } + return !this.editing; }, debounce: 250, variables() { - return { + if (!this.isEpic) { + return { + fullPath: this.attrWorkspacePath, + title: this.searchTerm, + state: this.$options.IssuableAttributeState[this.issuableAttribute], + }; + } + + const variables = { fullPath: this.attrWorkspacePath, - title: this.searchTerm, - in: this.searchTerm && this.issuableAttribute === IssuableType.Epic ? 'TITLE' : undefined, state: this.$options.IssuableAttributeState[this.issuableAttribute], - sort: this.issuableAttribute === IssuableType.Epic ? defaultEpicSort : null, + sort: defaultEpicSort, }; + + if (epicIidPattern.test(this.searchTerm)) { + const matches = this.searchTerm.match(epicIidPattern); + variables.iidStartsWith = matches.groups.iid; + } else if (this.searchTerm !== '') { + variables.in = 'TITLE'; + variables.title = this.searchTerm; + } + + return variables; }, update(data) { if (data?.workspace) { @@ -183,6 +205,9 @@ export default { attributeTypeIcon() { return this.icon || this.issuableAttribute; }, + tooltipText() { + return timeFor(this.currentAttribute?.dueDate); + }, i18n() { return { noAttribute: sprintf(s__('DropdownWidget|No %{issuableAttribute}'), { @@ -214,6 +239,9 @@ export default { ), }; }, + isEpic() { + return this.issuableAttribute === IssuableType.Epic; + }, }, methods: { updateAttribute(attributeId) { @@ -322,6 +350,7 @@ export default { :currentAttribute="currentAttribute" > <gl-link + v-gl-tooltip="tooltipText" class="gl-text-gray-900! gl-font-weight-bold" :href="attributeUrl" :data-qa-selector="`${issuableAttribute}_link`" diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue index 7c496cc422a..89aa03fd954 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue @@ -132,8 +132,9 @@ export default { <slot name="collapsed-right"></slot> <gl-button v-if="canUpdate && !initialLoading && canEdit" - variant="link" - class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto hide-collapsed" + category="tertiary" + size="small" + class="gl-text-gray-900! gl-ml-auto hide-collapsed gl-mr-n2" data-testid="edit-button" :data-track-event="tracking.event" :data-track-label="tracking.label" diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue index d1a5685fdd3..7c157fe2775 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue @@ -1,6 +1,5 @@ <script> -/* eslint-disable vue/no-v-html */ -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui'; import { joinPaths } from '~/lib/utils/url_utility'; import { sprintf, s__ } from '../../../locale'; @@ -9,15 +8,16 @@ export default { components: { GlButton, }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, computed: { href() { return joinPaths(gon.relative_url_root || '', '/help/user/project/time_tracking.md'); }, estimateText() { return sprintf( - s__( - 'estimateCommand|%{slash_command} will update the estimated time with the latest command.', - ), + s__('estimateCommand|%{slash_command} overwrites the total estimated time.'), { slash_command: '<code>/estimate</code>', }, @@ -26,7 +26,7 @@ export default { }, spendText() { return sprintf( - s__('spendCommand|%{slash_command} will update the sum of the time spent.'), + s__('spendCommand|%{slash_command} adds or subtracts time already spent.'), { slash_command: '<code>/spend</code>', }, @@ -41,9 +41,9 @@ export default { <div data-testid="helpPane" class="time-tracking-help-state"> <div class="time-tracking-info"> <h4>{{ __('Track time with quick actions') }}</h4> - <p>{{ __('Quick actions can be used in the issues description and comment boxes.') }}</p> - <p v-html="estimateText"></p> - <p v-html="spendText"></p> + <p>{{ __('Quick actions can be used in description and comment boxes.') }}</p> + <p v-safe-html="estimateText"></p> + <p v-safe-html="spendText"></p> <gl-button :href="href">{{ __('Learn more') }}</gl-button> </div> </div> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue index 8a14998910b..d4a8abb81a8 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue @@ -62,8 +62,8 @@ export default { formatDate(date) { return formatDate(date, TIME_DATE_FORMAT); }, - getNote(note) { - return note?.body; + getSummary(summary, note) { + return summary ?? note?.body; }, getTotalTimeSpent() { const seconds = this.report.reduce((acc, item) => acc + item.timeSpent, 0); @@ -81,7 +81,7 @@ export default { { key: 'spentAt', label: __('Spent At'), sortable: true }, { key: 'user', label: __('User'), sortable: true }, { key: 'timeSpent', label: __('Time Spent'), sortable: true }, - { key: 'note', label: __('Note'), sortable: true }, + { key: 'summary', label: __('Summary / Note'), sortable: true }, ], }; </script> @@ -107,8 +107,8 @@ export default { <div>{{ getTotalTimeSpent() }}</div> </template> - <template #cell(note)="{ item: { note } }"> - <div>{{ getNote(note) }}</div> + <template #cell(summary)="{ item: { summary, note } }"> + <div>{{ getSummary(summary, note) }}</div> </template> <template #foot(note)> </template> </gl-table> diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index 08ee4379c0c..fd43fb80b7f 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -48,6 +48,8 @@ export const ASSIGNEES_DEBOUNCE_DELAY = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; export const defaultEpicSort = 'TITLE_ASC'; +export const epicIidPattern = /^&(?<iid>\d+)$/; + export const assigneesQueries = { [IssuableType.Issue]: { query: getIssueAssignees, diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index dd1b439c482..031472a7d20 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -24,6 +24,7 @@ import SidebarDropdownWidget from '~/sidebar/components/sidebar_dropdown_widget. import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import { apolloProvider } from '~/sidebar/graphql'; import trackShowInviteMemberLink from '~/sidebar/track_invite_members'; +import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; import Translate from '../vue_shared/translate'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue'; @@ -256,6 +257,7 @@ export function mountSidebarLabels() { allowLabelEdit: parseBoolean(el.dataset.canEdit), allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels), initiallySelectedLabels: JSON.parse(el.dataset.selectedLabels), + variant: DropdownVariant.Sidebar, }, render: (createElement) => createElement(SidebarLabels), }); @@ -493,7 +495,7 @@ function mountSeverityComponent() { return false; } - const { fullPath, iid, severity } = getSidebarOptions(); + const { fullPath, iid, severity, editable } = getSidebarOptions(); return new Vue({ el: severityContainerEl, @@ -501,6 +503,9 @@ function mountSeverityComponent() { components: { SidebarSeverity, }, + provide: { + canUpdate: editable, + }, render: (createElement) => createElement('sidebar-severity', { props: { diff --git a/app/assets/javascripts/sidebar/queries/group_milestones.query.graphql b/app/assets/javascripts/sidebar/queries/group_milestones.query.graphql new file mode 100644 index 00000000000..dceab61ed26 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/group_milestones.query.graphql @@ -0,0 +1,20 @@ +#import "./milestone.fragment.graphql" + +query groupMilestones($fullPath: ID!, $title: String, $state: MilestoneStateEnum) { + workspace: group(fullPath: $fullPath) { + __typename + id + attributes: milestones( + searchTitle: $title + state: $state + sort: EXPIRED_LAST_DUE_DATE_ASC + first: 20 + includeAncestors: true + ) { + nodes { + ...MilestoneFragment + state + } + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/milestone.fragment.graphql b/app/assets/javascripts/sidebar/queries/milestone.fragment.graphql index 2ffd58a2da1..d4f7e703692 100644 --- a/app/assets/javascripts/sidebar/queries/milestone.fragment.graphql +++ b/app/assets/javascripts/sidebar/queries/milestone.fragment.graphql @@ -2,5 +2,6 @@ fragment MilestoneFragment on Milestone { id title webUrl: webPath + dueDate expired } diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue index 27b3a30b40a..8481ac2b9c9 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue @@ -29,15 +29,6 @@ export default { update(data) { return this.onContentUpdate(data); }, - result() { - if (this.activeViewerType === RICH_BLOB_VIEWER) { - // eslint-disable-next-line vue/no-mutating-props - this.blob.richViewer.renderError = null; - } else { - // eslint-disable-next-line vue/no-mutating-props - this.blob.simpleViewer.renderError = null; - } - }, skip() { return this.viewer.renderError; }, diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index a8f95748e7e..466b273cae4 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -54,6 +54,7 @@ export default { }, }, }, + inject: ['reportAbusePath'], props: { snippet: { type: Object, @@ -93,7 +94,6 @@ export default { click: this.showDeleteModal, variant: 'danger', category: 'secondary', - cssClass: 'ml-2', }, { condition: this.canCreateSnippet, @@ -103,10 +103,18 @@ export default { : joinPaths('/', gon.relative_url_root, '/-/snippets/new'), variant: 'success', category: 'secondary', - cssClass: 'ml-2', + }, + { + condition: this.reportAbusePath, + text: __('Submit as spam'), + href: this.reportAbusePath, + title: __('Submit as spam'), }, ]; }, + hasPersonalSnippetActions() { + return Boolean(this.personalSnippetActions.filter(({ condition }) => condition).length); + }, editLink() { return `${this.snippet.webUrl}/edit`; }, @@ -212,7 +220,7 @@ export default { </div> </div> - <div class="detail-page-header-actions"> + <div v-if="hasPersonalSnippetActions" class="detail-page-header-actions"> <div class="d-none d-sm-flex"> <template v-for="(action, index) in personalSnippetActions"> <div @@ -221,6 +229,7 @@ export default { v-gl-tooltip :title="action.title" class="d-inline-block" + :class="{ 'gl-ml-3': index > 0 }" > <gl-button :disabled="action.disabled" @@ -239,15 +248,17 @@ export default { </div> <div class="d-block d-sm-none dropdown"> <gl-dropdown :text="__('Options')" block> - <gl-dropdown-item - v-for="(action, index) in personalSnippetActions" - :key="index" - :disabled="action.disabled" - :title="action.title" - :href="action.href" - @click="action.click ? action.click() : undefined" - >{{ action.text }}</gl-dropdown-item - > + <template v-for="(action, index) in personalSnippetActions"> + <gl-dropdown-item + v-if="action.condition" + :key="index" + :disabled="action.disabled" + :title="action.title" + :href="action.href" + @click="action.click ? action.click() : undefined" + >{{ action.text }}</gl-dropdown-item + > + </template> </gl-dropdown> </div> </div> diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js index 789332ce5b7..dec8dcec179 100644 --- a/app/assets/javascripts/snippets/index.js +++ b/app/assets/javascripts/snippets/index.js @@ -14,13 +14,20 @@ export default function appFactory(el, Component) { } const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient({}, { batchMax: 1 }), + defaultClient: createDefaultClient( + {}, + { + batchMax: 1, + assumeImmutableResults: true, + }, + ), }); const { visibilityLevels = '[]', selectedLevel, multipleLevelsRestricted, + reportAbusePath, ...restDataset } = el.dataset; @@ -31,6 +38,7 @@ export default function appFactory(el, Component) { visibilityLevels: JSON.parse(visibilityLevels), selectedLevel: SNIPPET_LEVELS_MAP[selectedLevel] ?? SNIPPET_VISIBILITY_PRIVATE, multipleLevelsRestricted: 'multipleLevelsRestricted' in el.dataset, + reportAbusePath, }, render(createElement) { return createElement(Component, { diff --git a/app/assets/javascripts/snippets/mixins/snippets.js b/app/assets/javascripts/snippets/mixins/snippets.js index 7552eae97fc..b72befef56b 100644 --- a/app/assets/javascripts/snippets/mixins/snippets.js +++ b/app/assets/javascripts/snippets/mixins/snippets.js @@ -1,3 +1,4 @@ +import { isEmpty } from 'lodash'; import GetSnippetQuery from 'shared_queries/snippet/snippet.query.graphql'; const blobsDefault = []; @@ -12,20 +13,18 @@ export const getSnippetMixin = { }; }, update(data) { - const res = data.snippets.nodes[0]; + const res = { ...data.snippets.nodes[0] }; // Set `snippet.blobs` since some child components are coupled to this. - if (res) { + if (!isEmpty(res)) { // It's possible for us to not get any blobs in a response. // In this case, we should default to current blobs. - res.blobs = res.blobs ? res.blobs.nodes : this.blobs; + res.blobs = res.blobs ? res.blobs.nodes : blobsDefault; + res.description = res.description || ''; } return res; }, - result(res) { - this.blobs = res.data.snippets.nodes[0]?.blobs || blobsDefault; - }, skip() { return this.newSnippet; }, @@ -41,12 +40,14 @@ export const getSnippetMixin = { return { snippet: {}, newSnippet: !this.snippetGid, - blobs: blobsDefault, }; }, computed: { isLoading() { return this.$apollo.queries.snippet.loading; }, + blobs() { + return this.snippet?.blobs || []; + }, }, }; diff --git a/app/assets/javascripts/sourcegraph/load.js b/app/assets/javascripts/sourcegraph/load.js index f9491505d42..f41efc10d6c 100644 --- a/app/assets/javascripts/sourcegraph/load.js +++ b/app/assets/javascripts/sourcegraph/load.js @@ -1,6 +1,3 @@ import initSourcegraph from './index'; -/** - * Load sourcegraph in it's own listener so that it's isolated from failures. - */ -document.addEventListener('DOMContentLoaded', initSourcegraph); +initSourcegraph(); diff --git a/app/assets/javascripts/syntax_highlight.js b/app/assets/javascripts/syntax_highlight.js index 474b5132bc6..96b6e78c668 100644 --- a/app/assets/javascripts/syntax_highlight.js +++ b/app/assets/javascripts/syntax_highlight.js @@ -1,7 +1,5 @@ /* eslint-disable consistent-return */ -import $ from 'jquery'; - // Syntax Highlighter // // Applies a syntax highlighting color scheme CSS class to any element with the @@ -12,14 +10,30 @@ import $ from 'jquery'; // <div class="js-syntax-highlight"></div> // -export default function syntaxHighlight(el) { - if ($(el).hasClass('js-syntax-highlight')) { - // Given the element itself, apply highlighting - return $(el).addClass(gon.user_color_scheme); - } - // Given a parent element, recurse to any of its applicable children - const $children = $(el).find('.js-syntax-highlight'); - if ($children.length) { - return syntaxHighlight($children); +export default function syntaxHighlight($els = null) { + if (!$els) return; + + const els = $els.get ? $els.get() : $els; + const handler = (el) => { + if (el.classList.contains('js-syntax-highlight')) { + // Given the element itself, apply highlighting + return el.classList.add(gon.user_color_scheme); + } + // Given a parent element, recurse to any of its applicable children + const children = el.querySelectorAll('.js-syntax-highlight'); + if (children.length) { + return syntaxHighlight(children); + } + }; + + // In order to account for NodeList returned by document.querySelectorAll, + // we should rather check whether the els object is iterable + // instead of relying on Array.isArray() + const isIterable = typeof els[Symbol.iterator] === 'function'; + + if (isIterable) { + els.forEach((el) => handler(el)); + } else { + handler(els); } } diff --git a/app/assets/javascripts/terraform/components/empty_state.vue b/app/assets/javascripts/terraform/components/empty_state.vue index d86ba3af2b1..a5a613b7282 100644 --- a/app/assets/javascripts/terraform/components/empty_state.vue +++ b/app/assets/javascripts/terraform/components/empty_state.vue @@ -1,12 +1,12 @@ <script> -import { GlEmptyState, GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlEmptyState, GlIcon, GlLink } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; export default { components: { GlEmptyState, GlIcon, GlLink, - GlSprintf, }, props: { image: { @@ -14,6 +14,11 @@ export default { required: true, }, }, + computed: { + docsUrl() { + return helpPagePath('user/infrastructure/terraform_state'); + }, + }, }; </script> @@ -21,23 +26,10 @@ export default { <gl-empty-state :svg-path="image" :title="s__('Terraform|Get started with Terraform')"> <template #description> <p> - <gl-sprintf - :message=" - s__( - 'Terraform|Find out how to use the %{linkStart}GitLab managed Terraform State%{linkEnd}', - ) - " - > - <template #link="{ content }"> - <gl-link - href="https://docs.gitlab.com/ee/user/infrastructure/index.html" - target="_blank" - > - {{ content }} - <gl-icon name="external-link" /> - </gl-link> - </template> - </gl-sprintf> + <gl-link :href="docsUrl" target="_blank" + >{{ s__('Terraform|How to use GitLab-managed Terraform State?') }} + <gl-icon name="external-link" + /></gl-link> </p> </template> </gl-empty-state> diff --git a/app/assets/javascripts/terraform/components/init_command_modal.vue b/app/assets/javascripts/terraform/components/init_command_modal.vue new file mode 100644 index 00000000000..2cb10d4ae23 --- /dev/null +++ b/app/assets/javascripts/terraform/components/init_command_modal.vue @@ -0,0 +1,86 @@ +<script> +import { GlModal, GlSprintf, GlLink } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; + +export default { + i18n: { + title: s__('Terraform|Terraform init command'), + explanatoryText: s__( + `Terraform|To get access to this terraform state from your local computer, run the following command at the command line. The first line requires a personal access token with API read and write access. %{linkStart}How do I create a personal access token?%{linkEnd}.`, + ), + closeText: __('Close'), + copyToClipboardText: __('Copy'), + }, + components: { + GlModal, + GlSprintf, + GlLink, + ModalCopyButton, + }, + inject: ['accessTokensPath', 'terraformApiUrl', 'username'], + props: { + modalId: { + type: String, + required: true, + }, + stateName: { + type: String, + required: true, + }, + }, + computed: { + closeModalProps() { + return { + text: this.$options.i18n.closeText, + attributes: [], + }; + }, + }, + methods: { + getModalInfoCopyStr() { + return `export GITLAB_ACCESS_TOKEN=<YOUR-ACCESS-TOKEN> +terraform init \\ + -backend-config="address=${this.terraformApiUrl}/${this.stateName}" \\ + -backend-config="lock_address=${this.terraformApiUrl}/${this.stateName}/lock" \\ + -backend-config="unlock_address=${this.terraformApiUrl}/${this.stateName}/lock" \\ + -backend-config="username=${this.username}" \\ + -backend-config="password=$GITLAB_ACCESS_TOKEN" \\ + -backend-config="lock_method=POST" \\ + -backend-config="unlock_method=DELETE" \\ + -backend-config="retry_wait_min=5" + `; + }, + }, +}; +</script> + +<template> + <gl-modal + ref="initCommandModal" + :modal-id="modalId" + :title="$options.i18n.title" + :action-cancel="closeModalProps" + > + <p data-testid="init-command-explanatory-text"> + <gl-sprintf :message="$options.i18n.explanatoryText"> + <template #link="{ content }"> + <gl-link :href="accessTokensPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + + <div class="gl-display-flex"> + <pre class="gl-bg-gray gl-white-space-pre-wrap" data-testid="terraform-init-command">{{ + getModalInfoCopyStr() + }}</pre> + <modal-copy-button + :title="$options.i18n.copyToClipboardText" + :text="getModalInfoCopyStr()" + :modal-id="$options.modalId" + data-testid="init-command-copy-clipboard" + css-classes="gl-align-self-start gl-ml-2" + /> + </div> + </gl-modal> +</template> diff --git a/app/assets/javascripts/terraform/components/states_table_actions.vue b/app/assets/javascripts/terraform/components/states_table_actions.vue index c4fd97188de..817c421823c 100644 --- a/app/assets/javascripts/terraform/components/states_table_actions.vue +++ b/app/assets/javascripts/terraform/components/states_table_actions.vue @@ -8,12 +8,14 @@ import { GlIcon, GlModal, GlSprintf, + GlModalDirective, } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; import addDataToState from '../graphql/mutations/add_data_to_state.mutation.graphql'; import lockState from '../graphql/mutations/lock_state.mutation.graphql'; import removeState from '../graphql/mutations/remove_state.mutation.graphql'; import unlockState from '../graphql/mutations/unlock_state.mutation.graphql'; +import InitCommandModal from './init_command_modal.vue'; export default { components: { @@ -25,6 +27,10 @@ export default { GlIcon, GlModal, GlSprintf, + InitCommandModal, + }, + directives: { + GlModalDirective, }, props: { state: { @@ -36,6 +42,7 @@ export default { return { showRemoveModal: false, removeConfirmText: '', + showCommandModal: false, }; }, i18n: { @@ -43,7 +50,7 @@ export default { errorUpdate: s__('Terraform|An error occurred while changing the state file'), lock: s__('Terraform|Lock'), modalBody: s__( - 'Terraform|You are about to remove the State file %{name}. This will permanently delete all the State versions and history. The infrastructure provisioned previously will remain intact, only the state file with all its versions are to be removed. This action is non-revertible.', + 'Terraform|You are about to remove the state file %{name}. This will permanently delete all the State versions and history. The infrastructure provisioned previously will remain intact, and only the state file with all its versions will be removed. This action cannot be undone.', ), modalCancel: s__('Terraform|Cancel'), modalHeader: s__('Terraform|Are you sure you want to remove the Terraform State %{name}?'), @@ -54,6 +61,7 @@ export default { remove: s__('Terraform|Remove state file and versions'), removeSuccessful: s__('Terraform|%{name} successfully removed'), unlock: s__('Terraform|Unlock'), + copyCommand: s__('Terraform|Copy Terraform init command'), }, computed: { cancelModalProps() { @@ -74,6 +82,9 @@ export default { attributes: [{ disabled: this.disableModalSubmit }, { variant: 'danger' }], }; }, + commandModalId() { + return `init-command-modal-${this.state.name}`; + }, }, methods: { hideModal() { @@ -164,6 +175,9 @@ export default { }); }); }, + copyInitCommand() { + this.showCommandModal = true; + }, }, }; </script> @@ -182,6 +196,14 @@ export default { </template> <gl-dropdown-item + v-gl-modal-directive="commandModalId" + data-testid="terraform-state-copy-init-command" + @click="copyInitCommand" + > + {{ $options.i18n.copyCommand }} + </gl-dropdown-item> + + <gl-dropdown-item v-if="state.latestVersion" data-testid="terraform-state-download" :download="`${state.name}.json`" @@ -248,5 +270,11 @@ export default { /> </gl-form-group> </gl-modal> + + <init-command-modal + v-if="showCommandModal" + :modal-id="commandModalId" + :state-name="state.name" + /> </div> </template> diff --git a/app/assets/javascripts/terraform/index.js b/app/assets/javascripts/terraform/index.js index 3f986423836..1b8cab0d51e 100644 --- a/app/assets/javascripts/terraform/index.js +++ b/app/assets/javascripts/terraform/index.js @@ -24,11 +24,16 @@ export default () => { }, }); - const { emptyStateImage, projectPath } = el.dataset; + const { emptyStateImage, projectPath, accessTokensPath, terraformApiUrl, username } = el.dataset; return new Vue({ el, apolloProvider: new VueApollo({ defaultClient }), + provide: { + accessTokensPath, + terraformApiUrl, + username, + }, render(createElement) { return createElement(TerraformList, { props: { diff --git a/app/assets/javascripts/token_access/components/token_access.vue b/app/assets/javascripts/token_access/components/token_access.vue index 24565c441d8..e739ec37739 100644 --- a/app/assets/javascripts/token_access/components/token_access.vue +++ b/app/assets/javascripts/token_access/components/token_access.vue @@ -187,12 +187,7 @@ export default { /> </template> <template #footer> - <gl-button - variant="confirm" - :disabled="isProjectPathEmpty" - data-testid="add-project-button" - @click="addProject" - > + <gl-button variant="confirm" :disabled="isProjectPathEmpty" @click="addProject"> {{ $options.i18n.addProject }} </gl-button> <gl-button @click="clearTargetProjectPath">{{ $options.i18n.cancel }}</gl-button> diff --git a/app/assets/javascripts/token_access/components/token_projects_table.vue b/app/assets/javascripts/token_access/components/token_projects_table.vue index 777eda1c4d7..b6c9330c754 100644 --- a/app/assets/javascripts/token_access/components/token_projects_table.vue +++ b/app/assets/javascripts/token_access/components/token_projects_table.vue @@ -73,7 +73,6 @@ export default { variant="danger" icon="remove" :aria-label="__('Remove access')" - data-testid="remove-project-button" @click="removeProject(item.fullPath)" /> </template> diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js index cd0af59e4fe..598111e4086 100644 --- a/app/assets/javascripts/tracking/constants.js +++ b/app/assets/javascripts/tracking/constants.js @@ -1 +1,26 @@ export const SNOWPLOW_JS_SOURCE = 'gitlab-javascript'; + +export const DEFAULT_SNOWPLOW_OPTIONS = { + namespace: 'gl', + hostname: window.location.hostname, + cookieDomain: window.location.hostname, + appId: '', + userFingerprint: false, + respectDoNotTrack: true, + forceSecureTracker: true, + eventMethod: 'post', + contexts: { webPage: true, performanceTiming: true }, + formTracking: false, + linkClickTracking: false, + pageUnloadTimer: 10, + formTrackingConfig: { + forms: { allow: [] }, + fields: { allow: [] }, + }, +}; + +export const ACTION_ATTR_SELECTOR = '[data-track-action]'; +export const LOAD_ACTION_ATTR_SELECTOR = '[data-track-action="render"]'; + +export const DEPRECATED_EVENT_ATTR_SELECTOR = '[data-track-event]'; +export const DEPRECATED_LOAD_EVENT_ATTR_SELECTOR = '[data-track-event="render"]'; diff --git a/app/assets/javascripts/tracking/dispatch_snowplow_event.js b/app/assets/javascripts/tracking/dispatch_snowplow_event.js new file mode 100644 index 00000000000..bc9d7384ea4 --- /dev/null +++ b/app/assets/javascripts/tracking/dispatch_snowplow_event.js @@ -0,0 +1,23 @@ +import getStandardContext from './get_standard_context'; + +export function dispatchSnowplowEvent( + category = document.body.dataset.page, + action = 'generic', + data = {}, +) { + if (!category) { + /* eslint-disable-next-line @gitlab/require-i18n-strings */ + throw new Error('Tracking: no category provided for tracking.'); + } + + const { label, property, value, extra = {} } = data; + + const standardContext = getStandardContext({ extra }); + const contexts = [standardContext]; + + if (data.context) { + contexts.push(data.context); + } + + return window.snowplow('trackStructEvent', category, action, label, property, value, contexts); +} diff --git a/app/assets/javascripts/tracking/index.js b/app/assets/javascripts/tracking/index.js index 3714cac3fba..5417e2d969b 100644 --- a/app/assets/javascripts/tracking/index.js +++ b/app/assets/javascripts/tracking/index.js @@ -1,239 +1,20 @@ -import { omitBy, isUndefined } from 'lodash'; -import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; -import { getExperimentData } from '~/experimentation/utils'; +import { DEFAULT_SNOWPLOW_OPTIONS } from './constants'; import getStandardContext from './get_standard_context'; +import Tracking from './tracking'; -const DEFAULT_SNOWPLOW_OPTIONS = { - namespace: 'gl', - hostname: window.location.hostname, - cookieDomain: window.location.hostname, - appId: '', - userFingerprint: false, - respectDoNotTrack: true, - forceSecureTracker: true, - eventMethod: 'post', - contexts: { webPage: true, performanceTiming: true }, - formTracking: false, - linkClickTracking: false, - pageUnloadTimer: 10, - formTrackingConfig: { - forms: { allow: [] }, - fields: { allow: [] }, - }, -}; - -const addExperimentContext = (opts) => { - const { experiment, ...options } = opts; - if (experiment) { - const data = getExperimentData(experiment); - if (data) { - const context = { schema: TRACKING_CONTEXT_SCHEMA, data }; - return { ...options, context }; - } - } - return options; -}; - -const renameKey = (o, oldKey, newKey) => { - const ret = {}; - delete Object.assign(ret, o, { [newKey]: o[oldKey] })[oldKey]; - return ret; -}; - -const createEventPayload = (el, { suffix = '' } = {}) => { - const { - trackAction, - trackEvent, - trackValue, - trackExtra, - trackExperiment, - trackContext, - trackLabel, - trackProperty, - } = el?.dataset || {}; - - const action = (trackAction || trackEvent) + (suffix || ''); - let value = trackValue || el.value || undefined; - if (el.type === 'checkbox' && !el.checked) value = 0; - - let extra = trackExtra; - - if (extra !== undefined) { - try { - extra = JSON.parse(extra); - } catch (e) { - extra = undefined; - } - } - - const context = addExperimentContext({ - experiment: trackExperiment, - context: trackContext, - }); - - const data = { - label: trackLabel, - property: trackProperty, - value, - extra, - ...context, - }; - - return { - action, - data: omitBy(data, isUndefined), - }; -}; - -const eventHandler = (e, func, opts = {}) => { - const el = e.target.closest('[data-track-event], [data-track-action]'); - - if (!el) return; - - const { action, data } = createEventPayload(el, opts); - func(opts.category, action, data); -}; - -const eventHandlers = (category, func) => { - const handler = (opts) => (e) => eventHandler(e, func, { ...{ category }, ...opts }); - const handlers = []; - handlers.push({ name: 'click', func: handler() }); - handlers.push({ name: 'show.bs.dropdown', func: handler({ suffix: '_show' }) }); - handlers.push({ name: 'hide.bs.dropdown', func: handler({ suffix: '_hide' }) }); - return handlers; -}; - -const dispatchEvent = (category = document.body.dataset.page, action = 'generic', data = {}) => { - // eslint-disable-next-line @gitlab/require-i18n-strings - if (!category) throw new Error('Tracking: no category provided for tracking.'); - - const { label, property, value, extra = {} } = data; - - const standardContext = getStandardContext({ extra }); - const contexts = [standardContext]; - - if (data.context) { - contexts.push(data.context); - } - - return window.snowplow('trackStructEvent', category, action, label, property, value, contexts); -}; - -export default class Tracking { - static queuedEvents = []; - static initialized = false; - - static trackable() { - return !['1', 'yes'].includes( - window.doNotTrack || navigator.doNotTrack || navigator.msDoNotTrack, - ); - } - - static flushPendingEvents() { - this.initialized = true; - - while (this.queuedEvents.length) { - dispatchEvent(...this.queuedEvents.shift()); - } - } - - static enabled() { - return typeof window.snowplow === 'function' && this.trackable(); - } - - static event(...eventData) { - if (!this.enabled()) return false; - - if (!this.initialized) { - this.queuedEvents.push(eventData); - return false; - } - - return dispatchEvent(...eventData); - } - - static bindDocument(category = document.body.dataset.page, parent = document) { - if (!this.enabled() || parent.trackingBound) return []; - - // eslint-disable-next-line no-param-reassign - parent.trackingBound = true; - - const handlers = eventHandlers(category, (...args) => this.event(...args)); - handlers.forEach((event) => parent.addEventListener(event.name, event.func)); - return handlers; - } - - static trackLoadEvents(category = document.body.dataset.page, parent = document) { - if (!this.enabled()) return []; - - const loadEvents = parent.querySelectorAll( - '[data-track-action="render"], [data-track-event="render"]', - ); - - loadEvents.forEach((element) => { - const { action, data } = createEventPayload(element); - this.event(category, action, data); - }); - - return loadEvents; - } - - static enableFormTracking(config, contexts = []) { - if (!this.enabled()) return; - - if (!Array.isArray(config?.forms?.allow) && !Array.isArray(config?.fields?.allow)) { - // eslint-disable-next-line @gitlab/require-i18n-strings - throw new Error('Unable to enable form event tracking without allow rules.'); - } - - // Ignore default/standard schema - const standardContext = getStandardContext(); - const userProvidedContexts = contexts.filter( - (context) => context.schema !== standardContext.schema, - ); - - const mappedConfig = {}; - if (config.forms) mappedConfig.forms = renameKey(config.forms, 'allow', 'whitelist'); - if (config.fields) mappedConfig.fields = renameKey(config.fields, 'allow', 'whitelist'); - - const enabler = () => window.snowplow('enableFormTracking', mappedConfig, userProvidedContexts); - - if (document.readyState === 'complete') enabler(); - else { - document.addEventListener('readystatechange', () => { - if (document.readyState === 'complete') enabler(); - }); - } - } - - static mixin(opts = {}) { - return { - computed: { - trackingCategory() { - const localCategory = this.tracking ? this.tracking.category : null; - return localCategory || opts.category; - }, - trackingOptions() { - const options = addExperimentContext(opts); - return { ...options, ...this.tracking }; - }, - }, - methods: { - track(action, data = {}) { - const category = data.category || this.trackingCategory; - const options = { - ...this.trackingOptions, - ...data, - }; - Tracking.event(category, action, options); - }, - }, - }; - } -} +export { Tracking as default }; +/** + * Tracker initialization as defined in: + * https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/javascript-trackers/javascript-tracker/javascript-tracker-v2/tracker-setup/initializing-a-tracker-2/. + * It also dispatches any event emitted before its execution. + * + * @returns {undefined} + */ export function initUserTracking() { - if (!Tracking.enabled()) return; + if (!Tracking.enabled()) { + return; + } const opts = { ...DEFAULT_SNOWPLOW_OPTIONS, ...window.snowplowOptions }; window.snowplow('newTracker', opts.namespace, opts.hostname, opts); @@ -242,8 +23,18 @@ export function initUserTracking() { Tracking.flushPendingEvents(); } +/** + * Enables tracking of built-in events: page views, page pings. + * Optionally enables form and link tracking (automatically). + * Attaches event handlers for data-attributes powered events, and + * load-events (on render). + * + * @returns {undefined} + */ export function initDefaultTrackers() { - if (!Tracking.enabled()) return; + if (!Tracking.enabled()) { + return; + } const opts = { ...DEFAULT_SNOWPLOW_OPTIONS, ...window.snowplowOptions }; @@ -252,8 +43,13 @@ export function initDefaultTrackers() { const standardContext = getStandardContext(); window.snowplow('trackPageView', null, [standardContext]); - if (window.snowplowOptions.formTracking) Tracking.enableFormTracking(opts.formTrackingConfig); - if (window.snowplowOptions.linkClickTracking) window.snowplow('enableLinkClickTracking'); + if (window.snowplowOptions.formTracking) { + Tracking.enableFormTracking(opts.formTrackingConfig); + } + + if (window.snowplowOptions.linkClickTracking) { + window.snowplow('enableLinkClickTracking'); + } Tracking.bindDocument(); Tracking.trackLoadEvents(); diff --git a/app/assets/javascripts/tracking/tracking.js b/app/assets/javascripts/tracking/tracking.js new file mode 100644 index 00000000000..a1f745bc172 --- /dev/null +++ b/app/assets/javascripts/tracking/tracking.js @@ -0,0 +1,193 @@ +import { LOAD_ACTION_ATTR_SELECTOR, DEPRECATED_LOAD_EVENT_ATTR_SELECTOR } from './constants'; +import { dispatchSnowplowEvent } from './dispatch_snowplow_event'; +import getStandardContext from './get_standard_context'; +import { getEventHandlers, createEventPayload, renameKey, addExperimentContext } from './utils'; + +export default class Tracking { + static queuedEvents = []; + static initialized = false; + + /** + * (Legacy) Determines if tracking is enabled at the user level. + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/DNT. + * + * @returns {Boolean} + */ + static trackable() { + return !['1', 'yes'].includes( + window.doNotTrack || navigator.doNotTrack || navigator.msDoNotTrack, + ); + } + + /** + * Determines if Snowplow is available/enabled. + * + * @returns {Boolean} + */ + static enabled() { + return typeof window.snowplow === 'function' && this.trackable(); + } + + /** + * Dispatches a structured event per our taxonomy: + * https://docs.gitlab.com/ee/development/snowplow/index.html#structured-event-taxonomy. + * + * If the library is not initialized and events are trying to be + * dispatched (data-attributes, load-events), they will be added + * to a queue to be flushed afterwards. + * + * @param {...any} eventData defined event taxonomy + * @returns {undefined|Boolean} + */ + static event(...eventData) { + if (!this.enabled()) { + return false; + } + + if (!this.initialized) { + this.queuedEvents.push(eventData); + return false; + } + + return dispatchSnowplowEvent(...eventData); + } + + /** + * Dispatches any event emitted before initialization. + * + * @returns {undefined} + */ + static flushPendingEvents() { + this.initialized = true; + + while (this.queuedEvents.length) { + dispatchSnowplowEvent(...this.queuedEvents.shift()); + } + } + + /** + * Attaches event handlers for data-attributes powered events. + * + * @param {String} category - the default category for all events + * @param {HTMLElement} parent - element containing data-attributes + * @returns {Array} + */ + static bindDocument(category = document.body.dataset.page, parent = document) { + if (!this.enabled() || parent.trackingBound) { + return []; + } + + // eslint-disable-next-line no-param-reassign + parent.trackingBound = true; + + const handlers = getEventHandlers(category, (...args) => this.event(...args)); + handlers.forEach((event) => parent.addEventListener(event.name, event.func)); + + return handlers; + } + + /** + * Attaches event handlers for load-events (on render). + * + * @param {String} category - the default category for all events + * @param {HTMLElement} parent - element containing event targets + * @returns {Array} + */ + static trackLoadEvents(category = document.body.dataset.page, parent = document) { + if (!this.enabled()) { + return []; + } + + const loadEvents = parent.querySelectorAll( + `${LOAD_ACTION_ATTR_SELECTOR}, ${DEPRECATED_LOAD_EVENT_ATTR_SELECTOR}`, + ); + + loadEvents.forEach((element) => { + const { action, data } = createEventPayload(element); + this.event(category, action, data); + }); + + return loadEvents; + } + + /** + * Enable Snowplow automatic form tracking. + * The config param requires at least one array of either forms + * class names, or field name attributes. + * https://docs.gitlab.com/ee/development/snowplow/index.html#form-tracking. + * + * @param {Object} config + * @param {Array} contexts + * @returns {undefined} + */ + static enableFormTracking(config, contexts = []) { + if (!this.enabled()) { + return; + } + + if (!Array.isArray(config?.forms?.allow) && !Array.isArray(config?.fields?.allow)) { + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('Unable to enable form event tracking without allow rules.'); + } + + // Ignore default/standard schema + const standardContext = getStandardContext(); + const userProvidedContexts = contexts.filter( + (context) => context.schema !== standardContext.schema, + ); + + const mappedConfig = {}; + if (config.forms) { + mappedConfig.forms = renameKey(config.forms, 'allow', 'whitelist'); + } + + if (config.fields) { + mappedConfig.fields = renameKey(config.fields, 'allow', 'whitelist'); + } + + const enabler = () => window.snowplow('enableFormTracking', mappedConfig, userProvidedContexts); + + if (document.readyState === 'complete') { + enabler(); + } else { + document.addEventListener('readystatechange', () => { + if (document.readyState === 'complete') { + enabler(); + } + }); + } + } + + /** + * Returns an implementation of this class in the form of + * a Vue mixin. + * + * @param {Object} opts - default options for all events + * @returns {Object} + */ + static mixin(opts = {}) { + return { + computed: { + trackingCategory() { + const localCategory = this.tracking ? this.tracking.category : null; + return localCategory || opts.category; + }, + trackingOptions() { + const options = addExperimentContext(opts); + return { ...options, ...this.tracking }; + }, + }, + methods: { + track(action, data = {}) { + const category = data.category || this.trackingCategory; + const options = { + ...this.trackingOptions, + ...data, + }; + + Tracking.event(category, action, options); + }, + }, + }; + } +} diff --git a/app/assets/javascripts/tracking/utils.js b/app/assets/javascripts/tracking/utils.js new file mode 100644 index 00000000000..1189b2168ad --- /dev/null +++ b/app/assets/javascripts/tracking/utils.js @@ -0,0 +1,102 @@ +import { omitBy, isUndefined } from 'lodash'; +import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; +import { getExperimentData } from '~/experimentation/utils'; +import { + ACTION_ATTR_SELECTOR, + LOAD_ACTION_ATTR_SELECTOR, + DEPRECATED_EVENT_ATTR_SELECTOR, + DEPRECATED_LOAD_EVENT_ATTR_SELECTOR, +} from './constants'; + +export const addExperimentContext = (opts) => { + const { experiment, ...options } = opts; + + if (experiment) { + const data = getExperimentData(experiment); + if (data) { + const context = { schema: TRACKING_CONTEXT_SCHEMA, data }; + return { ...options, context }; + } + } + + return options; +}; + +export const createEventPayload = (el, { suffix = '' } = {}) => { + const { + trackAction, + trackEvent, + trackValue, + trackExtra, + trackExperiment, + trackContext, + trackLabel, + trackProperty, + } = el?.dataset || {}; + + const action = (trackAction || trackEvent) + (suffix || ''); + let value = trackValue || el.value || undefined; + + if (el.type === 'checkbox' && !el.checked) { + value = 0; + } + + let extra = trackExtra; + + if (extra !== undefined) { + try { + extra = JSON.parse(extra); + } catch (e) { + extra = undefined; + } + } + + const context = addExperimentContext({ + experiment: trackExperiment, + context: trackContext, + }); + + const data = { + label: trackLabel, + property: trackProperty, + value, + extra, + ...context, + }; + + return { + action, + data: omitBy(data, isUndefined), + }; +}; + +export const eventHandler = (e, func, opts = {}) => { + const actionSelector = `${ACTION_ATTR_SELECTOR}:not(${LOAD_ACTION_ATTR_SELECTOR})`; + const deprecatedEventSelector = `${DEPRECATED_EVENT_ATTR_SELECTOR}:not(${DEPRECATED_LOAD_EVENT_ATTR_SELECTOR})`; + const el = e.target.closest(`${actionSelector}, ${deprecatedEventSelector}`); + + if (!el) { + return; + } + + const { action, data } = createEventPayload(el, opts); + func(opts.category, action, data); +}; + +export const getEventHandlers = (category, func) => { + const handler = (opts) => (e) => eventHandler(e, func, { ...{ category }, ...opts }); + const handlers = []; + + handlers.push({ name: 'click', func: handler() }); + handlers.push({ name: 'show.bs.dropdown', func: handler({ suffix: '_show' }) }); + handlers.push({ name: 'hide.bs.dropdown', func: handler({ suffix: '_hide' }) }); + + return handlers; +}; + +export const renameKey = (o, oldKey, newKey) => { + const ret = {}; + delete Object.assign(ret, o, { [newKey]: o[oldKey] })[oldKey]; + + return ret; +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue index d79da9d3b90..41edbc83cdb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue @@ -20,21 +20,6 @@ export default { type: Boolean, required: true, }, - showVisualReviewApp: { - type: Boolean, - required: false, - default: false, - }, - visualReviewAppMeta: { - type: Object, - required: false, - default: () => ({ - sourceProjectId: '', - sourceProjectPath: '', - mergeRequestId: '', - appUrl: '', - }), - }, }, computed: { computedDeploymentStatus() { @@ -63,8 +48,6 @@ export default { <deployment-actions :deployment="deployment" :computed-deployment-status="computedDeploymentStatus" - :show-visual-review-app="showVisualReviewApp" - :visual-review-app-meta="visualReviewAppMeta" /> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue index 7e587663c26..5ef7c2f72e0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue @@ -33,21 +33,6 @@ export default { type: Object, required: true, }, - showVisualReviewApp: { - type: Boolean, - required: false, - default: false, - }, - visualReviewAppMeta: { - type: Object, - required: false, - default: () => ({ - sourceProjectId: '', - sourceProjectPath: '', - mergeRequestId: '', - appUrl: '', - }), - }, }, data() { return { @@ -178,8 +163,6 @@ export default { v-if="hasExternalUrls" :app-button-text="appButtonText" :deployment="deployment" - :show-visual-review-app="showVisualReviewApp" - :visual-review-app-meta="visualReviewAppMeta" /> <deployment-action-button v-if="stopUrl" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue index d23c7f016fb..d3384903cce 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue @@ -32,11 +32,6 @@ export default { appUrl: '', }), }, - showVisualReviewAppLink: { - type: Boolean, - required: false, - default: false, - }, }, computed: { showCollapsedDeployments() { @@ -74,8 +69,6 @@ export default { class="gl-bg-gray-50" :deployment="deployment" :show-metrics="hasDeploymentMetrics" - :show-visual-review-app="showVisualReviewAppLink" - :visual-review-app-meta="visualReviewAppMeta" /> </mr-collapsible-extension> <div v-else class="mr-widget-extension"> @@ -85,8 +78,6 @@ export default { :class="deploymentClass" :deployment="deployment" :show-metrics="hasDeploymentMetrics" - :show-visual-review-app="showVisualReviewAppLink" - :visual-review-app-meta="visualReviewAppMeta" /> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue index 459bee8023f..1e363b0f5fb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue @@ -20,8 +20,6 @@ export default { GlLink, GlSearchBoxByType, ReviewAppLink, - VisualReviewAppLink: () => - import('ee_component/vue_merge_request_widget/components/visual_review_app_link.vue'), }, directives: { autofocusonshow, @@ -35,21 +33,6 @@ export default { type: Object, required: true, }, - showVisualReviewApp: { - type: Boolean, - required: false, - default: false, - }, - visualReviewAppMeta: { - type: Object, - required: false, - default: () => ({ - sourceProjectId: '', - sourceProjectPath: '', - mergeRequestId: '', - appUrl: '', - }), - }, }, data() { return { searchTerm: '' }; @@ -114,12 +97,5 @@ export default { size="small" css-class="js-deploy-url deploy-link btn btn-default btn-sm inline gl-ml-3" /> - <visual-review-app-link - v-if="showVisualReviewApp" - :view-app-display="appButtonText" - :link="deploymentExternalUrl" - :app-metadata="visualReviewAppMeta" - :changes="deployment.changes" - /> </span> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue index 5ed699acddf..f71b1fbc539 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue @@ -28,7 +28,12 @@ export default { }; </script> <template> - <a v-gl-tooltip :href="authorUrl" :title="author.name" class="author-link inline"> + <a + v-gl-tooltip + :href="authorUrl" + :title="showAuthorName ? null : author.name" + class="author-link inline" + > <img :src="avatarUrl" class="avatar avatar-inline s16" /> <span v-if="showAuthorName" class="author">{{ author.name }}</span> </a> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index 5e401fc17e9..966262944ad 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -14,6 +14,7 @@ import { mergeUrlParams, webIDEUrl } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; import MrWidgetHowToMergeModal from './mr_widget_how_to_merge_modal.vue'; import MrWidgetIcon from './mr_widget_icon.vue'; @@ -30,6 +31,7 @@ export default { GlDropdownItem, GlLink, GlSprintf, + WebIdeLink, }, directives: { GlTooltip: GlTooltipDirective, @@ -56,31 +58,24 @@ export default { }); }, webIdePath() { - if (this.mr.canPushToSourceBranch) { - return mergeUrlParams( - { - target_project: - this.mr.sourceProjectFullPath !== this.mr.targetProjectFullPath - ? this.mr.targetProjectFullPath - : '', - }, - webIDEUrl(`/${this.mr.sourceProjectFullPath}/merge_requests/${this.mr.iid}`), - ); - } - - return null; - }, - ideButtonTitle() { - return !this.mr.canPushToSourceBranch - ? s__( - 'mrWidget|You are not allowed to edit this project directly. Please fork to make changes.', - ) - : ''; + return mergeUrlParams( + { + target_project: + this.mr.sourceProjectFullPath !== this.mr.targetProjectFullPath + ? this.mr.targetProjectFullPath + : '', + }, + webIDEUrl(`/${this.mr.sourceProjectFullPath}/merge_requests/${this.mr.iid}`), + ); }, isFork() { return this.mr.sourceProjectFullPath !== this.mr.targetProjectFullPath; }, }, + i18n: { + webIdeText: s__('mrWidget|Open in Web IDE'), + gitpodText: s__('mrWidget|Open in Gitpod'), + }, }; </script> <template> @@ -123,22 +118,21 @@ export default { <div class="branch-actions d-flex"> <template v-if="mr.isOpen"> - <span + <web-ide-link v-if="!mr.sourceBranchRemoved" - v-gl-tooltip - :title="ideButtonTitle" - class="gl-display-none d-md-inline-block gl-mr-3" - :tabindex="ideButtonTitle ? 0 : null" - > - <gl-button - :href="webIdePath" - :disabled="!mr.canPushToSourceBranch" - class="js-web-ide" - data-qa-selector="open_in_web_ide_button" - > - {{ s__('mrWidget|Open in Web IDE') }} - </gl-button> - </span> + :show-edit-button="false" + :show-web-ide-button="true" + :web-ide-url="webIdePath" + :web-ide-text="$options.i18n.webIdeText" + :show-gitpod-button="mr.showGitpodButton" + :gitpod-url="mr.gitpodUrl" + :gitpod-enabled="mr.gitpodEnabled" + :gitpod-text="$options.i18n.gitpodText" + class="gl-display-none gl-md-display-inline-block gl-mr-3" + data-placement="bottom" + tabindex="0" + data-qa-selector="open_in_web_ide_button" + /> <gl-button v-gl-modal-directive="'modal-merge-info'" :disabled="mr.sourceBranchRemoved" 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 c24ae92db4f..a8272002f16 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 @@ -66,11 +66,6 @@ export default { pipeline() { return this.isPostMerge ? this.mr.mergePipeline : this.mr.pipeline; }, - showVisualReviewAppLink() { - return Boolean( - this.mr.visualReviewAppAvailable && this.glFeatures.anonymousVisualReviewFeedback, - ); - }, showMergeTrainPositionIndicator() { return isNumber(this.mr.mergeTrainIndex); }, @@ -120,8 +115,6 @@ export default { :deployments="deployments" :deployment-class="deploymentClass" :has-deployment-metrics="hasDeploymentMetrics" - :visual-review-app-meta="visualReviewAppMeta" - :show-visual-review-app-link="showVisualReviewAppLink" /> <merge-train-position-indicator v-if="showMergeTrainPositionIndicator" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue index 43317130b08..ac6368a3025 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue @@ -1,6 +1,6 @@ <script> /* eslint-disable vue/no-v-html */ -import { s__ } from '~/locale'; +import { s__, n__ } from '~/locale'; export default { name: 'MRWidgetRelatedLinks', @@ -24,7 +24,8 @@ export default { if (this.state === 'closed') { return s__('mrWidget|Did not close'); } - return s__('mrWidget|Closes'); + + return n__('mrWidget|Closes issue', 'mrWidget|Closes issues', this.relatedLinks.closingCount); }, }, }; @@ -33,7 +34,8 @@ export default { <section class="mr-info-list gl-ml-7 gl-pb-5"> <p v-if="relatedLinks.closing">{{ closesText }} <span v-html="relatedLinks.closing"></span></p> <p v-if="relatedLinks.mentioned"> - {{ s__('mrWidget|Mentions') }} <span v-html="relatedLinks.mentioned"></span> + {{ n__('mrWidget|Mentions issue', 'mrWidget|Mentions issues', relatedLinks.mentionedCount) }} + <span v-html="relatedLinks.mentioned"></span> </p> <p v-if="relatedLinks.assignToMe"><span v-html="relatedLinks.assignToMe"></span></p> </section> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue index f99b825ff30..0eb173edbcb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue @@ -1,5 +1,5 @@ <script> -import { GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui'; +import { GlSkeletonLoader, GlIcon, GlButton, GlSprintf } from '@gitlab/ui'; import autoMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/auto_merge'; import autoMergeEnabledQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql'; import createFlash from '~/flash'; @@ -10,7 +10,6 @@ import { AUTO_MERGE_STRATEGIES } from '../../constants'; import eventHub from '../../event_hub'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import MrWidgetAuthor from '../mr_widget_author.vue'; -import statusIcon from '../mr_widget_status_icon.vue'; export default { name: 'MRWidgetAutoMergeEnabled', @@ -28,21 +27,20 @@ export default { }, components: { MrWidgetAuthor, - statusIcon, - GlLoadingIcon, GlSkeletonLoader, + GlIcon, + GlButton, + GlSprintf, }, mixins: [autoMergeMixin, glFeatureFlagMixin(), mergeRequestQueryVariablesMixin], props: { mr: { type: Object, required: true, - default: () => ({}), }, service: { type: Object, required: true, - default: () => ({}), }, }, data() { @@ -155,54 +153,44 @@ export default { </gl-skeleton-loader> </div> <template v-else> - <status-icon status="success" /> + <gl-icon name="status_scheduled" :size="24" class="gl-text-blue-500 gl-mr-3 gl-mt-1" /> <div class="media-body"> <h4 class="gl-display-flex"> <span class="gl-mr-3"> - <span class="js-status-text-before-author" data-testid="beforeStatusText">{{ - statusTextBeforeAuthor - }}</span> - <mr-widget-author :author="mergeUser" /> - <span class="js-status-text-after-author" data-testid="afterStatusText">{{ - statusTextAfterAuthor - }}</span> + <gl-sprintf :message="statusText" data-testid="statusText"> + <template #merge_author> + <mr-widget-author :author="mergeUser" /> + </template> + </gl-sprintf> </span> - <a + <gl-button v-if="mr.canCancelAutomaticMerge" - :disabled="isCancellingAutoMerge" - role="button" - href="#" - class="btn btn-sm btn-default js-cancel-auto-merge" + :loading="isCancellingAutoMerge" + size="small" + class="js-cancel-auto-merge" data-qa-selector="cancel_auto_merge_button" data-testid="cancelAutomaticMergeButton" - @click.prevent="cancelAutomaticMerge" + @click="cancelAutomaticMerge" > - <gl-loading-icon v-if="isCancellingAutoMerge" size="sm" inline class="gl-mr-1" /> {{ cancelButtonText }} - </a> + </gl-button> </h4> <section class="mr-info-list"> - <p> - {{ s__('mrWidget|The changes will be merged into') }} - <a :href="mr.targetBranchPath" class="label-branch">{{ targetBranch }}</a> - </p> <p v-if="shouldRemoveSourceBranch"> {{ s__('mrWidget|The source branch will be deleted') }} </p> <p v-else class="gl-display-flex"> <span class="gl-mr-3">{{ s__('mrWidget|The source branch will not be deleted') }}</span> - <a + <gl-button v-if="canRemoveSourceBranch" - :disabled="isRemovingSourceBranch" - role="button" - class="btn btn-sm btn-default js-remove-source-branch" - href="#" + :loading="isRemovingSourceBranch" + size="small" + class="js-remove-source-branch" data-testid="removeSourceBranchButton" - @click.prevent="removeSourceBranch" + @click="removeSourceBranch" > - <gl-loading-icon v-if="isRemovingSourceBranch" size="sm" inline class="gl-mr-1" /> {{ s__('mrWidget|Delete source branch') }} - </a> + </gl-button> </p> </section> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue index 302a30dab54..6d5ca58aa20 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue @@ -14,7 +14,6 @@ export default { mr: { type: Object, required: true, - default: () => ({}), }, }, }; 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 5a93021978c..1596f852b74 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 @@ -45,7 +45,6 @@ export default { mr: { type: Object, required: true, - default: () => ({}), }, }, data() { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue index e973a2350a3..42e9261b82c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue @@ -17,7 +17,6 @@ export default { mr: { type: Object, required: true, - default: () => ({}), }, }, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue index 5177eab790b..a1759b1a815 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue @@ -25,12 +25,10 @@ export default { mr: { type: Object, required: true, - default: () => ({}), }, service: { type: Object, required: true, - default: () => ({}), }, }, data() { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue index 32749b8b018..1c245b584ea 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue @@ -11,7 +11,6 @@ export default { mr: { type: Object, required: true, - default: () => ({}), }, }, data() { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index 2d0b7fe46a6..f33f4d3fda0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -188,13 +188,6 @@ export default { return this.mr.preferredAutoMergeStrategy; }, - isSHAMismatch() { - if (this.glFeatures.mergeRequestWidgetGraphql) { - return this.mr.sha !== this.state.diffHeadSha; - } - - return this.mr.isSHAMismatch; - }, squashIsSelected() { if (this.glFeatures.mergeRequestWidgetGraphql) { return this.isSquashReadOnly ? this.state.squashOnMerge : this.state.squash; @@ -573,21 +566,6 @@ export default { </div> </template> </div> - <div v-if="isSHAMismatch" class="d-flex align-items-center mt-2 js-sha-mismatch"> - <gl-icon name="warning-solid" class="text-warning mr-1" /> - <span class="text-warning"> - <gl-sprintf - :message=" - __('New changes were added. %{linkStart}Reload the page to review them%{linkEnd}') - " - > - <template #link="{ content }"> - <gl-link :href="mr.mergeRequestDiffsPath">{{ content }}</gl-link> - </template> - </gl-sprintf> - </span> - </div> - <div v-if="showDangerMessageForMergeTrain" class="gl-mt-5 gl-text-gray-500" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue index 89edf588213..7eeba8d8f89 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue @@ -1,24 +1,42 @@ <script> +import { GlButton } from '@gitlab/ui'; +import { I18N_SHA_MISMATCH } from '../../i18n'; import statusIcon from '../mr_widget_status_icon.vue'; export default { name: 'ShaMismatch', components: { statusIcon, + GlButton, + }, + i18n: { + I18N_SHA_MISMATCH, + }, + props: { + mr: { + type: Object, + required: true, + }, }, }; </script> <template> <div class="mr-widget-body media"> - <status-icon :show-disabled-button="true" status="warning" /> - <div class="media-body space-children"> - <span class="bold" data-qa-selector="head_mismatch_content"> - {{ - s__(`mrWidget|The source branch HEAD has recently changed. -Please reload the page and review the changes before merging`) - }} + <status-icon :show-disabled-button="false" status="warning" /> + <div class="media-body"> + <span class="gl-font-weight-bold" data-qa-selector="head_mismatch_content"> + {{ $options.i18n.I18N_SHA_MISMATCH.warningMessage }} </span> + <gl-button + class="gl-ml-3" + data-testid="action-button" + size="small" + category="primary" + variant="confirm" + :href="mr.mergeRequestDiffsPath" + >{{ $options.i18n.I18N_SHA_MISMATCH.actionButtonLabel }}</gl-button + > </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/i18n.js b/app/assets/javascripts/vue_merge_request_widget/i18n.js index e8e522a01e9..c88e795e5f3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/i18n.js +++ b/app/assets/javascripts/vue_merge_request_widget/i18n.js @@ -5,3 +5,8 @@ export const SQUASH_BEFORE_MERGE = { checkboxLabel: __('Squash commits'), helpLabel: __('What is squashing?'), }; + +export const I18N_SHA_MISMATCH = { + warningMessage: __('Merge blocked: new changes were just added.'), + actionButtonLabel: __('Review changes'), +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/auto_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/auto_merge.js index 67d9892d9c6..de77ed7ec9c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/auto_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/auto_merge.js @@ -2,14 +2,13 @@ import { s__ } from '~/locale'; export default { computed: { - statusTextBeforeAuthor() { - return s__('mrWidget|Set by'); - }, - statusTextAfterAuthor() { - return s__('mrWidget|to be merged automatically when the pipeline succeeds'); + statusText() { + return s__( + 'mrWidget|Set by %{merge_author} to be merged automatically when the pipeline succeeds', + ); }, cancelButtonText() { - return s__('mrWidget|Cancel'); + return s__('mrWidget|Cancel auto-merge'); }, }, }; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 5fe04269e33..a8a9df598f5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -38,6 +38,7 @@ import RebaseState from './components/states/mr_widget_rebase.vue'; import NothingToMergeState from './components/states/nothing_to_merge.vue'; import PipelineFailedState from './components/states/pipeline_failed.vue'; import ReadyToMergeState from './components/states/ready_to_merge.vue'; +import ShaMismatch from './components/states/sha_mismatch.vue'; import UnresolvedDiscussionsState from './components/states/unresolved_discussions.vue'; import WorkInProgressState from './components/states/work_in_progress.vue'; // import ExtensionsContainer from './components/extensions/container'; @@ -72,7 +73,7 @@ export default { 'mr-widget-not-allowed': NotAllowedState, 'mr-widget-missing-branch': MissingBranchState, 'mr-widget-ready-to-merge': ReadyToMergeState, - 'sha-mismatch': ReadyToMergeState, + 'sha-mismatch': ShaMismatch, 'mr-widget-checking': CheckingState, 'mr-widget-unresolved-discussions': UnresolvedDiscussionsState, 'mr-widget-pipeline-blocked': PipelineBlockedState, @@ -150,7 +151,7 @@ export default { ); }, shouldRenderCodeQuality() { - return this.mr?.codeclimate?.head_path; + return this.mr?.codequalityReportsPath; }, shouldRenderRelatedLinks() { return Boolean(this.mr.relatedLinks) && !this.mr.isNothingToMergeState; @@ -496,8 +497,6 @@ export default { <!-- <extensions-container :mr="mr" /> --> <grouped-codequality-reports-app v-if="shouldRenderCodeQuality" - :base-path="mr.codeclimate.base_path" - :head-path="mr.codeclimate.head_path" :head-blob-path="mr.headBlobPath" :base-blob-path="mr.baseBlobPath" :codequality-reports-path="mr.codequalityReportsPath" diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index 9d3f4eb01ed..04800cf43f0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -23,8 +23,8 @@ export default function deviseState() { return stateKey.pipelineBlocked; } else if (this.canMerge && this.isSHAMismatch) { return stateKey.shaMismatch; - } else if (this.autoMergeEnabled) { - return this.mergeError ? stateKey.autoMergeFailed : stateKey.autoMergeEnabled; + } else if (this.autoMergeEnabled && !this.mergeError) { + return stateKey.autoMergeEnabled; } else if (!this.canMerge) { return stateKey.notAllowedToMerge; } else if (this.canBeMerged) { diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 8e3160ce2f2..8979fe621ac 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -19,6 +19,7 @@ export default class MergeRequestStore { this.setPaths(data); this.setData(data); + this.setGitpodData(data); } setData(data, isRebased) { @@ -71,7 +72,13 @@ export default class MergeRequestStore { const assignToMe = links.assign_to_closing; if (closing || mentioned || assignToMe) { - this.relatedLinks = { closing, mentioned, assignToMe }; + this.relatedLinks = { + closing, + mentioned, + assignToMe, + closingCount: links.closing_count, + mentionedCount: links.mentioned_count, + }; } } @@ -199,6 +206,12 @@ export default class MergeRequestStore { } } + setGitpodData(data) { + this.showGitpodButton = data.show_gitpod_button; + this.gitpodUrl = data.gitpod_url; + this.gitpodEnabled = data.gitpod_enabled; + } + setState() { if (this.mergeOngoing) { this.state = 'merging'; @@ -261,7 +274,6 @@ export default class MergeRequestStore { this.baseBlobPath = blobPath.base_path || ''; this.codequalityReportsPath = data.codequality_reports_path; this.codequalityHelpPath = data.codequality_help_path; - this.codeclimate = data.codeclimate; // Security reports this.sastComparisonPath = data.sast_comparison_path; diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js new file mode 100644 index 00000000000..eeed5e9dc3a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js @@ -0,0 +1,27 @@ +/* eslint-disable @gitlab/require-i18n-strings */ + +import { __ } from '~/locale'; +import DropdownWidget from './dropdown_widget.vue'; + +export default { + component: DropdownWidget, + title: 'vue_shared/components/dropdown/dropdown_widget/dropdown_widget', +}; + +const Template = (args, { argTypes }) => ({ + components: { DropdownWidget }, + props: Object.keys(argTypes), + template: '<dropdown-widget v-bind="$props" v-on="$props" />', +}); + +export const Default = Template.bind({}); +Default.args = { + options: [ + { id: 'gid://gitlab/Milestone/-1', title: __('Any Milestone') }, + { id: 'gid://gitlab/Milestone/0', title: __('No Milestone') }, + { id: 'gid://gitlab/Milestone/-2', title: __('Upcoming') }, + { id: 'gid://gitlab/Milestone/-3', title: __('Started') }, + ], + selectText: 'Select', + searchText: 'Search', +}; diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue new file mode 100644 index 00000000000..7859ef85dd8 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue @@ -0,0 +1,165 @@ +<script> +import { + GlLoadingIcon, + GlDropdown, + GlDropdownForm, + GlDropdownDivider, + GlDropdownItem, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlLoadingIcon, + GlDropdown, + GlDropdownForm, + GlDropdownDivider, + GlDropdownItem, + GlSearchBoxByType, + }, + props: { + selectText: { + type: String, + required: false, + default: __('Select'), + }, + searchText: { + type: String, + required: false, + default: __('Search'), + }, + presetOptions: { + type: Array, + required: false, + default: () => [], + }, + options: { + type: Array, + required: false, + default: () => [], + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + selected: { + type: Object, + required: false, + default: () => {}, + }, + searchTerm: { + type: String, + required: false, + default: '', + }, + }, + computed: { + isSearchEmpty() { + return this.searchTerm === '' && !this.isLoading; + }, + noOptionsFound() { + return !this.isSearchEmpty && this.options.length === 0; + }, + }, + methods: { + selectOption(option) { + this.$emit('set-option', option || null); + }, + isSelected(option) { + return ( + this.selected && + ((option.name && this.selected.name === option.name) || + (option.title && this.selected.title === option.title)) + ); + }, + showDropdown() { + this.$refs.dropdown.show(); + }, + setFocus() { + this.$refs.search.focusInput(); + }, + setSearchTerm(search) { + this.$emit('set-search', search); + }, + avatarUrl(option) { + return option.avatar_url || option.avatarUrl || null; + }, + secondaryText(option) { + // TODO: this has some knowledge of the context where the component is used. We could later rework it. + return option.username || null; + }, + }, + i18n: { + noMatchingResults: __('No matching results'), + }, +}; +</script> + +<template> + <gl-dropdown + ref="dropdown" + :text="selectText" + lazy + menu-class="gl-w-full!" + class="gl-w-full" + v-on="$listeners" + @shown="setFocus" + > + <template #header> + <gl-search-box-by-type + ref="search" + :value="searchTerm" + :placeholder="searchText" + class="js-dropdown-input-field" + @input="setSearchTerm" + /> + </template> + <gl-dropdown-form class="gl-relative gl-min-h-7"> + <gl-loading-icon + v-if="isLoading" + size="md" + class="gl-absolute gl-left-0 gl-top-0 gl-right-0" + /> + <template v-else> + <template v-if="isSearchEmpty && presetOptions.length > 0"> + <gl-dropdown-item + v-for="option in presetOptions" + :key="option.id" + :is-checked="isSelected(option)" + :is-check-centered="true" + :is-check-item="true" + @click="selectOption(option)" + > + <slot name="preset-item" :item="option"> + {{ option.title }} + </slot> + </gl-dropdown-item> + <gl-dropdown-divider /> + </template> + <gl-dropdown-item + v-for="option in options" + :key="option.id" + :is-checked="isSelected(option)" + :is-check-centered="true" + :is-check-item="true" + :avatar-url="avatarUrl(option)" + :secondary-text="secondaryText(option)" + data-testid="unselected-option" + @click="selectOption(option)" + > + <slot name="item" :item="option"> + {{ option.title }} + </slot> + </gl-dropdown-item> + <gl-dropdown-item v-if="noOptionsFound" class="gl-pl-6!"> + {{ $options.i18n.noMatchingResults }} + </gl-dropdown-item> + </template> + </gl-dropdown-form> + <template #footer> + <slot name="footer"></slot> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js index 994ce6a762a..2e9634819a0 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js @@ -2,10 +2,14 @@ import { __ } from '~/locale'; export const DEBOUNCE_DELAY = 200; export const MAX_RECENT_TOKENS_SIZE = 3; +export const WEIGHT_TOKEN_SUGGESTIONS_SIZE = 21; export const FILTER_NONE = 'None'; export const FILTER_ANY = 'Any'; export const FILTER_CURRENT = 'Current'; +export const FILTER_UPCOMING = 'Upcoming'; +export const FILTER_STARTED = 'Started'; +export const FILTER_NONE_ANY = [FILTER_NONE, FILTER_ANY]; export const OPERATOR_IS = '='; export const OPERATOR_IS_TEXT = __('is'); @@ -24,11 +28,9 @@ export const DEFAULT_ITERATIONS = DEFAULT_NONE_ANY.concat([ { value: FILTER_CURRENT, text: __(FILTER_CURRENT) }, ]); -export const DEFAULT_LABELS = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; - export const DEFAULT_MILESTONES = DEFAULT_NONE_ANY.concat([ - { value: 'Upcoming', text: __('Upcoming') }, // eslint-disable-line @gitlab/require-i18n-strings - { value: 'Started', text: __('Started') }, // eslint-disable-line @gitlab/require-i18n-strings + { value: FILTER_UPCOMING, text: __(FILTER_UPCOMING) }, + { value: FILTER_STARTED, text: __(FILTER_STARTED) }, ]); export const SortDirection = { @@ -36,12 +38,14 @@ export const SortDirection = { ascending: 'ascending', }; +export const FILTERED_SEARCH_LABELS = 'labels'; export const FILTERED_SEARCH_TERM = 'filtered-search-term'; export const TOKEN_TITLE_AUTHOR = __('Author'); export const TOKEN_TITLE_ASSIGNEE = __('Assignee'); export const TOKEN_TITLE_MILESTONE = __('Milestone'); export const TOKEN_TITLE_LABEL = __('Label'); +export const TOKEN_TITLE_TYPE = __('Type'); export const TOKEN_TITLE_MY_REACTION = __('My-Reaction'); export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential'); export const TOKEN_TITLE_ITERATION = __('Iteration'); diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index 5ab287150f2..9dc5c5db276 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -16,7 +16,7 @@ import createFlash from '~/flash'; import { __ } from '~/locale'; import { SortDirection } from './constants'; -import { stripQuotes, uniqueTokens } from './filtered_search_utils'; +import { filterEmptySearchTerm, stripQuotes, uniqueTokens } from './filtered_search_utils'; export default { components: { @@ -223,9 +223,14 @@ export default { // Put any searches that may have come in before // we fetched the saved searches ahead of the already saved ones - const resultantSearches = this.recentSearchesStore.setRecentSearches( + let resultantSearches = this.recentSearchesStore.setRecentSearches( this.recentSearchesStore.state.recentSearches.concat(searches), ); + // If visited URL has search params, add them to recent search store + if (filterEmptySearchTerm(this.filterValue).length) { + resultantSearches = this.recentSearchesStore.addRecentSearch(this.filterValue); + } + this.recentSearchesService.save(resultantSearches); this.recentSearches = resultantSearches; }); diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js index 571d24b50cf..6573f366b52 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js @@ -247,3 +247,12 @@ export function setTokenValueToRecentlyUsed(recentSuggestionsStorageKey, tokenVa ); } } + +/** + * Removes `FILTERED_SEARCH_TERM` tokens with empty data + * + * @param filterTokens array of filtered search tokens + * @return {Array} array of filtered search tokens + */ +export const filterEmptySearchTerm = (filterTokens = []) => + filterTokens.filter((token) => token.type === FILTERED_SEARCH_TERM && token.value.data); diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue index a25a19a006c..ae5d3965de1 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue @@ -31,19 +31,25 @@ export default { data() { return { authors: this.config.initialAuthors || [], - defaultAuthors: this.config.defaultAuthors || [DEFAULT_LABEL_ANY], - preloadedAuthors: this.config.preloadedAuthors || [], loading: false, }; }, + computed: { + defaultAuthors() { + return this.config.defaultAuthors || [DEFAULT_LABEL_ANY]; + }, + preloadedAuthors() { + return this.config.preloadedAuthors || []; + }, + }, methods: { - getActiveAuthor(authors, currentValue) { - return authors.find((author) => author.username.toLowerCase() === currentValue); + getActiveAuthor(authors, data) { + return authors.find((author) => author.username.toLowerCase() === data.toLowerCase()); }, getAvatarUrl(author) { return author.avatarUrl || author.avatar_url; }, - fetchAuthorBySearchTerm(searchTerm) { + fetchAuthors(searchTerm) { this.loading = true; const fetchPromise = this.config.fetchPath ? this.config.fetchAuthors(this.config.fetchPath, searchTerm) @@ -76,11 +82,11 @@ export default { :active="active" :suggestions-loading="loading" :suggestions="authors" - :fn-active-token-value="getActiveAuthor" + :get-active-token-value="getActiveAuthor" :default-suggestions="defaultAuthors" :preloaded-suggestions="preloadedAuthors" :recent-suggestions-storage-key="config.recentSuggestionsStorageKey" - @fetch-suggestions="fetchAuthorBySearchTerm" + @fetch-suggestions="fetchAuthors" v-on="$listeners" > <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> @@ -91,7 +97,7 @@ export default { shape="circle" class="gl-mr-2" /> - <span>{{ activeTokenValue ? activeTokenValue.name : inputValue }}</span> + {{ activeTokenValue ? activeTokenValue.name : inputValue }} </template> <template #suggestions-list="{ suggestions }"> <gl-filtered-search-suggestion diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue index a4804525a53..d1326e96794 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue @@ -8,7 +8,7 @@ import { } from '@gitlab/ui'; import { debounce } from 'lodash'; -import { DEBOUNCE_DELAY } from '../constants'; +import { DEBOUNCE_DELAY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants'; import { getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed } from '../filtered_search_utils'; export default { @@ -42,12 +42,10 @@ export default { required: false, default: () => [], }, - fnActiveTokenValue: { + getActiveTokenValue: { type: Function, required: false, - default: (suggestions, currentTokenValue) => { - return suggestions.find(({ value }) => value === currentTokenValue); - }, + default: (suggestions, data) => suggestions.find(({ value }) => value === data), }, defaultSuggestions: { type: Array, @@ -69,11 +67,6 @@ export default { required: false, default: 'id', }, - fnCurrentTokenValue: { - type: Function, - required: false, - default: null, - }, }, data() { return { @@ -81,7 +74,6 @@ export default { recentSuggestions: this.recentSuggestionsStorageKey ? getRecentlyUsedSuggestions(this.recentSuggestionsStorageKey) : [], - loading: false, }; }, computed: { @@ -94,14 +86,16 @@ export default { preloadedTokenIds() { return this.preloadedSuggestions.map((tokenValue) => tokenValue[this.valueIdentifier]); }, - currentTokenValue() { - if (this.fnCurrentTokenValue) { - return this.fnCurrentTokenValue(this.value.data); - } - return this.value.data.toLowerCase(); - }, activeTokenValue() { - return this.fnActiveTokenValue(this.suggestions, this.currentTokenValue); + return this.getActiveTokenValue(this.suggestions, this.value.data); + }, + availableDefaultSuggestions() { + if (this.value.operator === OPERATOR_IS_NOT) { + return this.defaultSuggestions.filter( + (suggestion) => !FILTER_NONE_ANY.includes(suggestion.value), + ); + } + return this.defaultSuggestions; }, /** * Return all the suggestions when searchKey is present @@ -117,6 +111,29 @@ export default { !this.preloadedTokenIds.includes(tokenValue[this.valueIdentifier]), ); }, + showDefaultSuggestions() { + return this.availableDefaultSuggestions.length; + }, + showRecentSuggestions() { + return this.isRecentSuggestionsEnabled && this.recentSuggestions.length && !this.searchKey; + }, + showPreloadedSuggestions() { + return this.preloadedSuggestions.length && !this.searchKey; + }, + showAvailableSuggestions() { + return this.availableSuggestions.length; + }, + showSuggestions() { + // These conditions must match the template under `#suggestions` slot + // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/65817#note_632619411 + return ( + this.showDefaultSuggestions || + this.showRecentSuggestions || + this.showPreloadedSuggestions || + this.suggestionsLoading || + this.showAvailableSuggestions + ); + }, }, watch: { active: { @@ -168,10 +185,10 @@ export default { <template #view="viewTokenProps"> <slot name="view" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot> </template> - <template #suggestions> - <template v-if="defaultSuggestions.length"> + <template v-if="showSuggestions" #suggestions> + <template v-if="showDefaultSuggestions"> <gl-filtered-search-suggestion - v-for="token in defaultSuggestions" + v-for="token in availableDefaultSuggestions" :key="token.value" :value="token.value" > @@ -179,13 +196,13 @@ export default { </gl-filtered-search-suggestion> <gl-dropdown-divider /> </template> - <template v-if="isRecentSuggestionsEnabled && recentSuggestions.length && !searchKey"> + <template v-if="showRecentSuggestions"> <gl-dropdown-section-header>{{ __('Recently used') }}</gl-dropdown-section-header> <slot name="suggestions-list" :suggestions="recentSuggestions"></slot> <gl-dropdown-divider /> </template> <slot - v-if="preloadedSuggestions.length && !searchKey" + v-if="showPreloadedSuggestions" name="suggestions-list" :suggestions="preloadedSuggestions" ></slot> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue index 5859fd10688..4ecfc1cf40c 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue @@ -1,27 +1,19 @@ <script> -import { - GlToken, - GlFilteredSearchToken, - GlFilteredSearchSuggestion, - GlDropdownDivider, - GlLoadingIcon, -} from '@gitlab/ui'; -import { debounce } from 'lodash'; - +import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import createFlash from '~/flash'; import { __ } from '~/locale'; - -import { DEBOUNCE_DELAY } from '../constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; export default { components: { - GlToken, - GlFilteredSearchToken, + BaseToken, GlFilteredSearchSuggestion, - GlDropdownDivider, - GlLoadingIcon, }, props: { + active: { + type: Boolean, + required: true, + }, config: { type: Object, required: true, @@ -34,82 +26,62 @@ export default { data() { return { branches: this.config.initialBranches || [], - defaultBranches: this.config.defaultBranches || [], - loading: true, + loading: false, }; }, computed: { - currentValue() { - return this.value.data.toLowerCase(); - }, - activeBranch() { - return this.branches.find((branch) => branch.name.toLowerCase() === this.currentValue); - }, - }, - watch: { - active: { - immediate: true, - handler(newValue) { - if (!newValue && !this.branches.length) { - this.fetchBranchBySearchTerm(this.value.data); - } - }, + defaultBranches() { + return this.config.defaultBranches || []; }, }, methods: { - fetchBranchBySearchTerm(searchTerm) { + getActiveBranch(branches, data) { + return branches.find((branch) => branch.name.toLowerCase() === data.toLowerCase()); + }, + fetchBranches(searchTerm) { this.loading = true; this.config .fetchBranches(searchTerm) .then(({ data }) => { this.branches = data; }) - .catch(() => createFlash({ message: __('There was a problem fetching branches.') })) + .catch(() => { + createFlash({ message: __('There was a problem fetching branches.') }); + }) .finally(() => { this.loading = false; }); }, - searchBranches: debounce(function debouncedSearch({ data }) { - this.fetchBranchBySearchTerm(data); - }, DEBOUNCE_DELAY), }, }; </script> <template> - <gl-filtered-search-token + <base-token + :active="active" :config="config" - v-bind="{ ...$props, ...$attrs }" + :value="value" + :default-suggestions="defaultBranches" + :suggestions="branches" + :suggestions-loading="loading" + :get-active-token-value="getActiveBranch" + @fetch-suggestions="fetchBranches" v-on="$listeners" - @input="searchBranches" > - <template #view-token="{ inputValue }"> - <gl-token variant="search-value">{{ - activeBranch ? activeBranch.name : inputValue - }}</gl-token> + <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> + {{ activeTokenValue ? activeTokenValue.name : inputValue }} </template> - <template #suggestions> + <template #suggestions-list="{ suggestions }"> <gl-filtered-search-suggestion - v-for="branch in defaultBranches" - :key="branch.value" - :value="branch.value" + v-for="branch in suggestions" + :key="branch.id" + :value="branch.name" > - {{ branch.text }} + <div class="gl-display-flex"> + <span class="gl-display-inline-block gl-mr-3 gl-p-3"></span> + {{ branch.name }} + </div> </gl-filtered-search-suggestion> - <gl-dropdown-divider v-if="defaultBranches.length" /> - <gl-loading-icon v-if="loading" size="sm" /> - <template v-else> - <gl-filtered-search-suggestion - v-for="branch in branches" - :key="branch.id" - :value="branch.name" - > - <div class="gl-display-flex"> - <span class="gl-display-inline-block gl-mr-3 gl-p-3"></span> - <div>{{ branch.name }}</div> - </div> - </gl-filtered-search-suggestion> - </template> </template> - </gl-filtered-search-token> + </base-token> </template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue index d186f46866c..5a69751a2cc 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue @@ -1,26 +1,21 @@ <script> -import { - GlFilteredSearchToken, - GlFilteredSearchSuggestion, - GlDropdownDivider, - GlLoadingIcon, -} from '@gitlab/ui'; -import { debounce } from 'lodash'; - +import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import createFlash from '~/flash'; import { __ } from '~/locale'; - -import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY } from '../constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { DEFAULT_NONE_ANY } from '../constants'; import { stripQuotes } from '../filtered_search_utils'; export default { components: { - GlFilteredSearchToken, + BaseToken, GlFilteredSearchSuggestion, - GlDropdownDivider, - GlLoadingIcon, }, props: { + active: { + type: Boolean, + required: true, + }, config: { type: Object, required: true, @@ -33,87 +28,63 @@ export default { data() { return { emojis: this.config.initialEmojis || [], - defaultEmojis: this.config.defaultEmojis || DEFAULT_NONE_ANY, - loading: true, + loading: false, }; }, computed: { - currentValue() { - return this.value.data.toLowerCase(); - }, - activeEmoji() { - return this.emojis.find( - (emoji) => emoji.name.toLowerCase() === stripQuotes(this.currentValue), - ); - }, - }, - watch: { - active: { - immediate: true, - handler(newValue) { - if (!newValue && !this.emojis.length) { - this.fetchEmojiBySearchTerm(this.value.data); - } - }, + defaultEmojis() { + return this.config.defaultEmojis || DEFAULT_NONE_ANY; }, }, methods: { - fetchEmojiBySearchTerm(searchTerm) { + getActiveEmoji(emojis, data) { + return emojis.find((emoji) => emoji.name.toLowerCase() === stripQuotes(data).toLowerCase()); + }, + fetchEmojis(searchTerm) { this.loading = true; this.config .fetchEmojis(searchTerm) - .then((res) => { - this.emojis = Array.isArray(res) ? res : res.data; + .then((response) => { + this.emojis = Array.isArray(response) ? response : response.data; + }) + .catch(() => { + createFlash({ message: __('There was a problem fetching emojis.') }); }) - .catch(() => - createFlash({ - message: __('There was a problem fetching emojis.'), - }), - ) .finally(() => { this.loading = false; }); }, - searchEmojis: debounce(function debouncedSearch({ data }) { - this.fetchEmojiBySearchTerm(data); - }, DEBOUNCE_DELAY), }, }; </script> <template> - <gl-filtered-search-token + <base-token + :active="active" :config="config" - v-bind="{ ...$props, ...$attrs }" + :value="value" + :default-suggestions="defaultEmojis" + :suggestions="emojis" + :suggestions-loading="loading" + :get-active-token-value="getActiveEmoji" + @fetch-suggestions="fetchEmojis" v-on="$listeners" - @input="searchEmojis" > - <template #view="{ inputValue }"> - <gl-emoji v-if="activeEmoji" :data-name="activeEmoji.name" /> - <span v-else>{{ inputValue }}</span> + <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> + <gl-emoji v-if="activeTokenValue" :data-name="activeTokenValue.name" /> + <template v-else>{{ inputValue }}</template> </template> - <template #suggestions> + <template #suggestions-list="{ suggestions }"> <gl-filtered-search-suggestion - v-for="emoji in defaultEmojis" - :key="emoji.value" - :value="emoji.value" + v-for="emoji in suggestions" + :key="emoji.name" + :value="emoji.name" > - {{ emoji.value }} + <div class="gl-display-flex"> + <gl-emoji class="gl-mr-3" :data-name="emoji.name" /> + {{ emoji.name }} + </div> </gl-filtered-search-suggestion> - <gl-dropdown-divider v-if="defaultEmojis.length" /> - <gl-loading-icon v-if="loading" size="sm" /> - <template v-else> - <gl-filtered-search-suggestion - v-for="emoji in emojis" - :key="emoji.name" - :value="emoji.name" - > - <div class="gl-display-flex"> - <gl-emoji :data-name="emoji.name" /> - <span class="gl-ml-3">{{ emoji.name }}</span> - </div> - </gl-filtered-search-suggestion> - </template> </template> - </gl-filtered-search-token> + </base-token> </template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue index aa234cf86d9..9f68308808e 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue @@ -8,7 +8,7 @@ import { import { debounce } from 'lodash'; import createFlash from '~/flash'; import { __ } from '~/locale'; -import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY } from '../constants'; +import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants'; export default { separator: '::&', @@ -48,6 +48,14 @@ export default { defaultEpics() { return this.config.defaultEpics || DEFAULT_NONE_ANY; }, + availableDefaultEpics() { + if (this.value.operator === OPERATOR_IS_NOT) { + return this.defaultEpics.filter( + (suggestion) => !FILTER_NONE_ANY.includes(suggestion.value), + ); + } + return this.defaultEpics; + }, activeEpic() { if (this.currentValue && this.epics.length) { // Check if current value is an epic ID. @@ -99,7 +107,7 @@ export default { // We don't have any information about selected token except for its // group path and iid joined by separator, so we need to manually // compose epic path from it. - if (data.includes(this.$options.separator)) { + if (data.includes?.(this.$options.separator)) { const [groupPath, epicIid] = data.split(this.$options.separator); epicPath = `/groups/${groupPath}/-/epics/${epicIid}`; } @@ -127,13 +135,13 @@ export default { </template> <template #suggestions> <gl-filtered-search-suggestion - v-for="epic in defaultEpics" + v-for="epic in availableDefaultEpics" :key="epic.value" :value="epic.value" > {{ epic.text }} </gl-filtered-search-suggestion> - <gl-dropdown-divider v-if="defaultEpics.length" /> + <gl-dropdown-divider v-if="availableDefaultEpics.length" /> <gl-loading-icon v-if="loading" size="sm" /> <template v-else> <gl-filtered-search-suggestion v-for="epic in epics" :key="epic.id" :value="getValue(epic)"> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue index ba8b2421726..c1d1bc7da91 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue @@ -1,24 +1,21 @@ <script> -import { - GlDropdownDivider, - GlFilteredSearchSuggestion, - GlFilteredSearchToken, - GlLoadingIcon, -} from '@gitlab/ui'; -import { debounce } from 'lodash'; +import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import createFlash from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; -import { DEBOUNCE_DELAY, DEFAULT_ITERATIONS } from '../constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { DEFAULT_ITERATIONS } from '../constants'; export default { components: { - GlDropdownDivider, + BaseToken, GlFilteredSearchSuggestion, - GlFilteredSearchToken, - GlLoadingIcon, }, props: { + active: { + type: Boolean, + required: true, + }, config: { type: Object, required: true, @@ -35,84 +32,58 @@ export default { }; }, computed: { - currentValue() { - return this.value.data; - }, - activeIteration() { - return this.iterations.find( - (iteration) => getIdFromGraphQLId(iteration.id) === Number(this.currentValue), - ); - }, defaultIterations() { return this.config.defaultIterations || DEFAULT_ITERATIONS; }, }, - watch: { - active: { - immediate: true, - handler(newValue) { - if (!newValue && !this.iterations.length) { - this.fetchIterationBySearchTerm(this.currentValue); - } - }, - }, - }, methods: { - getValue(iteration) { - return String(getIdFromGraphQLId(iteration.id)); + getActiveIteration(iterations, data) { + return iterations.find((iteration) => this.getValue(iteration) === data); }, - fetchIterationBySearchTerm(searchTerm) { - const fetchPromise = this.config.fetchPath - ? this.config.fetchIterations(this.config.fetchPath, searchTerm) - : this.config.fetchIterations(searchTerm); - + fetchIterations(searchTerm) { this.loading = true; - - fetchPromise + this.config + .fetchIterations(searchTerm) .then((response) => { this.iterations = Array.isArray(response) ? response : response.data; }) - .catch(() => createFlash({ message: __('There was a problem fetching iterations.') })) + .catch(() => { + createFlash({ message: __('There was a problem fetching iterations.') }); + }) .finally(() => { this.loading = false; }); }, - searchIterations: debounce(function debouncedSearch({ data }) { - this.fetchIterationBySearchTerm(data); - }, DEBOUNCE_DELAY), + getValue(iteration) { + return String(getIdFromGraphQLId(iteration.id)); + }, }, }; </script> <template> - <gl-filtered-search-token + <base-token + :active="active" :config="config" - v-bind="{ ...$props, ...$attrs }" + :value="value" + :default-suggestions="defaultIterations" + :suggestions="iterations" + :suggestions-loading="loading" + :get-active-token-value="getActiveIteration" + @fetch-suggestions="fetchIterations" v-on="$listeners" - @input="searchIterations" > - <template #view="{ inputValue }"> - {{ activeIteration ? activeIteration.title : inputValue }} + <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> + {{ activeTokenValue ? activeTokenValue.title : inputValue }} </template> - <template #suggestions> + <template #suggestions-list="{ suggestions }"> <gl-filtered-search-suggestion - v-for="iteration in defaultIterations" - :key="iteration.value" - :value="iteration.value" + v-for="iteration in suggestions" + :key="iteration.id" + :value="getValue(iteration)" > - {{ iteration.text }} + {{ iteration.title }} </gl-filtered-search-suggestion> - <gl-dropdown-divider v-if="defaultIterations.length" /> - <gl-loading-icon v-if="loading" size="sm" /> - <template v-else> - <gl-filtered-search-suggestion - v-for="iteration in iterations" - :key="iteration.id" - :value="getValue(iteration)" - > - {{ iteration.title }} - </gl-filtered-search-suggestion> - </template> </template> - </gl-filtered-search-token> + </base-token> </template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue index 4d08f81fee9..c31f3a25fb1 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue @@ -5,7 +5,7 @@ import createFlash from '~/flash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; -import { DEFAULT_LABELS } from '../constants'; +import { DEFAULT_NONE_ANY } from '../constants'; import { stripQuotes } from '../filtered_search_utils'; import BaseToken from './base_token.vue'; @@ -33,14 +33,18 @@ export default { data() { return { labels: this.config.initialLabels || [], - defaultLabels: this.config.defaultLabels || DEFAULT_LABELS, loading: false, }; }, + computed: { + defaultLabels() { + return this.config.defaultLabels || DEFAULT_NONE_ANY; + }, + }, methods: { - getActiveLabel(labels, currentValue) { + getActiveLabel(labels, data) { return labels.find( - (label) => this.getLabelName(label).toLowerCase() === stripQuotes(currentValue), + (label) => this.getLabelName(label).toLowerCase() === stripQuotes(data).toLowerCase(), ); }, /** @@ -68,7 +72,7 @@ export default { } return {}; }, - fetchLabelBySearchTerm(searchTerm) { + fetchLabels(searchTerm) { this.loading = true; this.config .fetchLabels(searchTerm) @@ -98,10 +102,10 @@ export default { :active="active" :suggestions-loading="loading" :suggestions="labels" - :fn-active-token-value="getActiveLabel" + :get-active-token-value="getActiveLabel" :default-suggestions="defaultLabels" :recent-suggestions-storage-key="config.recentSuggestionsStorageKey" - @fetch-suggestions="fetchLabelBySearchTerm" + @fetch-suggestions="fetchLabels" v-on="$listeners" > <template diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue index 66ad5ef5b4e..4b9ad6d8f91 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue @@ -1,27 +1,22 @@ <script> -import { - GlFilteredSearchToken, - GlFilteredSearchSuggestion, - GlDropdownDivider, - GlLoadingIcon, -} from '@gitlab/ui'; -import { debounce } from 'lodash'; - +import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import createFlash from '~/flash'; import { __ } from '~/locale'; import { sortMilestonesByDueDate } from '~/milestones/milestone_utils'; - -import { DEFAULT_MILESTONES, DEBOUNCE_DELAY } from '../constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { DEFAULT_MILESTONES } from '../constants'; import { stripQuotes } from '../filtered_search_utils'; export default { components: { - GlFilteredSearchToken, + BaseToken, GlFilteredSearchSuggestion, - GlDropdownDivider, - GlLoadingIcon, }, props: { + active: { + type: Boolean, + required: true, + }, config: { type: Object, required: true, @@ -34,36 +29,21 @@ export default { data() { return { milestones: this.config.initialMilestones || [], - defaultMilestones: this.config.defaultMilestones || DEFAULT_MILESTONES, loading: false, }; }, computed: { - currentValue() { - return this.value.data.toLowerCase(); - }, - activeMilestone() { - return this.milestones.find( - (milestone) => milestone.title.toLowerCase() === stripQuotes(this.currentValue), - ); - }, - }, - watch: { - active: { - immediate: true, - handler(newValue) { - if (!newValue && !this.milestones.length) { - this.fetchMilestoneBySearchTerm(this.value.data); - } - }, + defaultMilestones() { + return this.config.defaultMilestones || DEFAULT_MILESTONES; }, }, methods: { - fetchMilestoneBySearchTerm(searchTerm = '') { - if (this.loading) { - return; - } - + getActiveMilestone(milestones, data) { + return milestones.find( + (milestone) => milestone.title.toLowerCase() === stripQuotes(data).toLowerCase(), + ); + }, + fetchMilestones(searchTerm) { this.loading = true; this.config .fetchMilestones(searchTerm) @@ -71,47 +51,40 @@ export default { const data = Array.isArray(response) ? response : response.data; this.milestones = data.slice().sort(sortMilestonesByDueDate); }) - .catch(() => createFlash({ message: __('There was a problem fetching milestones.') })) + .catch(() => { + createFlash({ message: __('There was a problem fetching milestones.') }); + }) .finally(() => { this.loading = false; }); }, - searchMilestones: debounce(function debouncedSearch({ data }) { - this.fetchMilestoneBySearchTerm(data); - }, DEBOUNCE_DELAY), }, }; </script> <template> - <gl-filtered-search-token + <base-token + :active="active" :config="config" - v-bind="{ ...$props, ...$attrs }" + :value="value" + :default-suggestions="defaultMilestones" + :suggestions="milestones" + :suggestions-loading="loading" + :get-active-token-value="getActiveMilestone" + @fetch-suggestions="fetchMilestones" v-on="$listeners" - @input="searchMilestones" > - <template #view="{ inputValue }"> - <span>%{{ activeMilestone ? activeMilestone.title : inputValue }}</span> + <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> + %{{ activeTokenValue ? activeTokenValue.title : inputValue }} </template> - <template #suggestions> + <template #suggestions-list="{ suggestions }"> <gl-filtered-search-suggestion - v-for="milestone in defaultMilestones" - :key="milestone.value" - :value="milestone.value" + v-for="milestone in suggestions" + :key="milestone.id" + :value="milestone.title" > - {{ milestone.text }} + {{ milestone.title }} </gl-filtered-search-suggestion> - <gl-dropdown-divider v-if="defaultMilestones.length" /> - <gl-loading-icon v-if="loading" size="sm" /> - <template v-else> - <gl-filtered-search-suggestion - v-for="milestone in milestones" - :key="milestone.id" - :value="milestone.title" - > - <div>{{ milestone.title }}</div> - </gl-filtered-search-suggestion> - </template> </template> - </gl-filtered-search-token> + </base-token> </template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue index 72116f0e991..280fb234576 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue @@ -1,15 +1,20 @@ <script> -import { GlDropdownDivider, GlFilteredSearchSuggestion, GlFilteredSearchToken } from '@gitlab/ui'; -import { DEFAULT_NONE_ANY } from '../constants'; +import { GlFilteredSearchSuggestion } from '@gitlab/ui'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { DEFAULT_NONE_ANY, WEIGHT_TOKEN_SUGGESTIONS_SIZE } from '../constants'; + +const weights = Array.from(Array(WEIGHT_TOKEN_SUGGESTIONS_SIZE), (_, index) => index.toString()); export default { - baseWeights: ['0', '1', '2', '3', '4', '5'], components: { - GlDropdownDivider, + BaseToken, GlFilteredSearchSuggestion, - GlFilteredSearchToken, }, props: { + active: { + type: Boolean, + required: true, + }, config: { type: Object, required: true, @@ -21,38 +26,41 @@ export default { }, data() { return { - weights: this.$options.baseWeights, - defaultWeights: this.config.defaultWeights || DEFAULT_NONE_ANY, + weights, }; }, + computed: { + defaultWeights() { + return this.config.defaultWeights || DEFAULT_NONE_ANY; + }, + }, methods: { - updateWeights({ data }) { - const weight = parseInt(data, 10); - this.weights = Number.isNaN(weight) ? this.$options.baseWeights : [String(weight)]; + getActiveWeight(weightSuggestions, data) { + return weightSuggestions.find((weight) => weight === data); + }, + updateWeights(searchTerm) { + const weight = parseInt(searchTerm, 10); + this.weights = Number.isNaN(weight) ? weights : [String(weight)]; }, }, }; </script> <template> - <gl-filtered-search-token + <base-token + :active="active" :config="config" - v-bind="{ ...$props, ...$attrs }" + :value="value" + :default-suggestions="defaultWeights" + :suggestions="weights" + :get-active-token-value="getActiveWeight" + @fetch-suggestions="updateWeights" v-on="$listeners" - @input="updateWeights" > - <template #suggestions> - <gl-filtered-search-suggestion - v-for="weight in defaultWeights" - :key="weight.value" - :value="weight.value" - > - {{ weight.text }} - </gl-filtered-search-suggestion> - <gl-dropdown-divider v-if="defaultWeights.length" /> - <gl-filtered-search-suggestion v-for="weight of weights" :key="weight" :value="weight"> + <template #suggestions-list="{ suggestions }"> + <gl-filtered-search-suggestion v-for="weight of suggestions" :key="weight" :value="weight"> {{ weight }} </gl-filtered-search-suggestion> </template> - </gl-filtered-search-token> + </base-token> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index d343ba700ab..3ed9de6c133 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -1,5 +1,5 @@ <script> -import { GlPopover, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlPopover, GlButton, GlTooltipDirective } from '@gitlab/ui'; import $ from 'jquery'; import { keysFor, BOLD_TEXT, ITALIC_TEXT, LINK_TEXT } from '~/behaviors/shortcuts/keybindings'; import { getSelectedFragment } from '~/lib/utils/common_utils'; @@ -10,7 +10,6 @@ import ToolbarButton from './toolbar_button.vue'; export default { components: { ToolbarButton, - GlIcon, GlPopover, GlButton, }, @@ -46,6 +45,7 @@ export default { data() { return { tag: '> ', + suggestPopoverVisible: false, }; }, computed: { @@ -76,15 +76,27 @@ export default { return this.isMac ? '⌘' : s__('KeyboardKey|Ctrl+'); }, }, + watch: { + showSuggestPopover() { + this.updateSuggestPopoverVisibility(); + }, + }, mounted() { $(document).on('markdown-preview:show.vue', this.previewMarkdownTab); $(document).on('markdown-preview:hide.vue', this.writeMarkdownTab); + + this.updateSuggestPopoverVisibility(); }, beforeDestroy() { $(document).off('markdown-preview:show.vue', this.previewMarkdownTab); $(document).off('markdown-preview:hide.vue', this.writeMarkdownTab); }, methods: { + async updateSuggestPopoverVisibility() { + await this.$nextTick(); + + this.suggestPopoverVisible = this.showSuggestPopover && this.canSuggest; + }, isValid(form) { return ( !form || @@ -153,127 +165,114 @@ export default { </button> </li> <li :class="{ active: !previewMarkdown }" class="md-header-toolbar"> - <div class="d-inline-block"> - <toolbar-button - tag="**" - :button-title=" - sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { modifierKey }) - " - :shortcuts="$options.shortcuts.bold" - icon="bold" - /> - <toolbar-button - tag="_" - :button-title=" - sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { modifierKey }) - " - :shortcuts="$options.shortcuts.italic" - icon="italic" - /> + <toolbar-button + tag="**" + :button-title=" + sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { modifierKey }) + " + :shortcuts="$options.shortcuts.bold" + icon="bold" + /> + <toolbar-button + tag="_" + :button-title=" + sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { modifierKey }) + " + :shortcuts="$options.shortcuts.italic" + icon="italic" + /> + <toolbar-button + :prepend="true" + :tag="tag" + :button-title="__('Insert a quote')" + icon="quote" + @click="handleQuote" + /> + <template v-if="canSuggest"> <toolbar-button + ref="suggestButton" + :tag="mdSuggestion" :prepend="true" - :tag="tag" - :button-title="__('Insert a quote')" - icon="quote" - @click="handleQuote" + :button-title="__('Insert suggestion')" + :cursor-offset="4" + :tag-content="lineContent" + icon="doc-code" + data-qa-selector="suggestion_button" + class="js-suggestion-btn" + @click="handleSuggestDismissed" /> - </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" - data-qa-selector="suggestion_button" - class="js-suggestion-btn" + <gl-popover + v-if="suggestPopoverVisible" + :target="$refs.suggestButton.$el" + :css-classes="['diff-suggest-popover']" + placement="bottom" + :show="suggestPopoverVisible" + > + <strong>{{ __('New! Suggest changes directly') }}</strong> + <p class="mb-2"> + {{ + __( + 'Suggest code changes which can be immediately applied in one click. Try it out!', + ) + }} + </p> + <gl-button + variant="info" + category="primary" + size="small" @click="handleSuggestDismissed" - /> - <gl-popover - v-if="showSuggestPopover && $refs.suggestButton" - :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 can be immediately applied in one click. Try it out!', - ) - }} - </p> - <gl-button - variant="info" - category="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=" - sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), { modifierKey }) - " - :shortcuts="$options.shortcuts.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="list-task" - /> - <toolbar-button - :tag="mdCollapsibleSection" - :prepend="true" - tag-select="Click to expand" - :button-title="__('Add a collapsible section')" - icon="details-block" - /> - <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" - > - <gl-icon name="maximize" /> - </button> - </div> + {{ __('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=" + sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), { modifierKey }) + " + :shortcuts="$options.shortcuts.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="list-task" + /> + <toolbar-button + :tag="mdCollapsibleSection" + :prepend="true" + tag-select="Click to expand" + :button-title="__('Add a collapsible section')" + icon="details-block" + /> + <toolbar-button + :tag="mdTable" + :prepend="true" + :button-title="__('Add a table')" + icon="table" + /> + <toolbar-button + class="js-zen-enter" + :prepend="true" + :button-title="__('Go full screen')" + icon="maximize" + /> </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 6c35741e7e5..6a83939795c 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue @@ -1,9 +1,9 @@ <script> -import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlTooltipDirective, GlButton } from '@gitlab/ui'; export default { components: { - GlIcon, + GlButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -19,7 +19,8 @@ export default { }, tag: { type: String, - required: true, + required: false, + default: '', }, tagBlock: { type: String, @@ -71,7 +72,7 @@ export default { </script> <template> - <button + <gl-button v-gl-tooltip :data-md-tag="tag" :data-md-cursor-offset="cursorOffset" @@ -82,11 +83,11 @@ export default { :data-md-shortcuts="shortcutsString" :title="buttonTitle" :aria-label="buttonTitle" + :icon="icon" type="button" - class="toolbar-btn js-md" + category="tertiary" + class="js-md" data-container="body" @click="() => $emit('click')" - > - <gl-icon :name="icon" /> - </button> + /> </template> diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue index 79a9e1fca8c..8a67754993d 100644 --- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue +++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue @@ -42,12 +42,12 @@ export default { itemsCount: { type: Object, required: false, - default: () => {}, + default: () => ({}), }, pageInfo: { type: Object, required: false, - default: () => {}, + default: () => ({}), }, statusTabs: { type: Array, diff --git a/app/assets/javascripts/vue_shared/components/papa_parse_alert.vue b/app/assets/javascripts/vue_shared/components/papa_parse_alert.vue new file mode 100644 index 00000000000..fa11661255f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/papa_parse_alert.vue @@ -0,0 +1,44 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlAlert, + }, + i18n: { + genericErrorMessage: s__('CsvParser|Failed to render the CSV file for the following reasons:'), + MissingQuotes: s__('CsvParser|Quoted field unterminated'), + InvalidQuotes: s__('CsvParser|Trailing quote on quoted field is malformed'), + UndetectableDelimiter: s__('CsvParser|Unable to auto-detect delimiter; defaulted to ","'), + TooManyFields: s__('CsvParser|Too many fields'), + TooFewFields: s__('CsvParser|Too few fields'), + }, + props: { + papaParseErrors: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + errorMessages() { + const errorMessages = this.papaParseErrors.map( + (error) => this.$options.i18n[error.code] ?? error.message, + ); + return new Set(errorMessages); + }, + }, +}; +</script> + +<template> + <gl-alert variant="danger" :dismissible="false"> + {{ $options.i18n.genericErrorMessage }} + <ul class="gl-mb-0!"> + <li v-for="error in errorMessages" :key="error"> + {{ error }} + </li> + </ul> + </gl-alert> +</template> diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue index a0c5a0559de..f21092af501 100644 --- a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue @@ -15,6 +15,11 @@ export default { ProjectListItem, }, props: { + maxListHeight: { + type: Number, + required: false, + default: 402, + }, projectSearchResults: { type: Array, required: true, @@ -101,7 +106,7 @@ export default { <div class="d-flex flex-column"> <gl-loading-icon v-if="showLoadingIndicator" size="sm" class="py-2 px-4" /> <gl-infinite-scroll - :max-list-height="402" + :max-list-height="maxListHeight" :fetched-items="projectSearchResults.length" :total-items="totalResults" @bottomReached="bottomReached" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue index 9914bfc6026..623e7799493 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue @@ -132,6 +132,9 @@ export default { } else if (e.keyCode === ENTER_KEY_CODE && this.currentHighlightItem > -1) { this.updateSelectedLabels([this.visibleLabels[this.currentHighlightItem]]); this.searchKey = ''; + + // Prevent parent form submission upon hitting enter. + e.preventDefault(); } else if (e.keyCode === ESC_KEY_CODE) { this.toggleDropdownContents(); } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue index aad754e15b0..7989ad40b5a 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue @@ -28,8 +28,9 @@ export default { <template v-if="allowLabelEdit"> <gl-loading-icon v-show="labelsSelectInProgress" size="sm" inline /> <gl-button - variant="link" - class="float-right gl-text-gray-900! gl-hover-text-blue-800! js-sidebar-dropdown-toggle" + category="tertiary" + size="small" + class="float-right js-sidebar-dropdown-toggle gl-mr-n2" data-qa-selector="labels_edit_button" @click="toggleDropdownContents" > diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue index 87af3ffc52c..4234bc72f3a 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue @@ -142,6 +142,7 @@ export default { this.setInitialState({ selectedLabels, }); + setTimeout(() => this.updateLabelsSetState(), 100); }, showDropdownContents(showDropdownContents) { this.setContentIsOnViewport(showDropdownContents); @@ -184,7 +185,7 @@ export default { document.removeEventListener('click', this.handleDocumentClick); }, methods: { - ...mapActions(['setInitialState', 'toggleDropdownContents']), + ...mapActions(['setInitialState', 'toggleDropdownContents', 'updateLabelsSetState']), /** * This method differentiates between * dispatched actions and calls necessary method. @@ -315,7 +316,7 @@ export default { </dropdown-value> <dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" /> <dropdown-contents - v-show="dropdownButtonVisible && showDropdownContents" + v-if="dropdownButtonVisible && showDropdownContents" ref="dropdownContents" :render-on-top="!contentIsOnViewport" /> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js index 178be0f6da0..0c697e624ab 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js @@ -20,7 +20,11 @@ export const receiveLabelsFailure = ({ commit }) => { message: __('Error fetching labels.'), }); }; -export const fetchLabels = ({ state, dispatch }) => { +export const fetchLabels = ({ state, dispatch }, options) => { + if (state.labelsFetched && (!options || !options.refetch)) { + return Promise.resolve(); + } + dispatch('requestLabels'); return axios .get(state.labelsFetchPath) @@ -46,6 +50,7 @@ export const createLabel = ({ state, dispatch }, label) => { }) .then(({ data }) => { if (data.id) { + dispatch('fetchLabels', { refetch: true }); dispatch('receiveCreateLabelSuccess'); dispatch('toggleDropdownContentsCreateView'); } else { @@ -60,3 +65,5 @@ export const createLabel = ({ state, dispatch }, label) => { export const updateSelectedLabels = ({ commit }, labels) => commit(types.UPDATE_SELECTED_LABELS, { labels }); + +export const updateLabelsSetState = ({ commit }) => commit(types.UPDATE_LABELS_SET_STATE); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js index 2e044dc3b3c..f26e36031f4 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js @@ -18,3 +18,5 @@ export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS'; export const UPDATE_SELECTED_LABELS = 'UPDATE_SELECTED_LABELS'; export const TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW = 'TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW'; + +export const UPDATE_LABELS_SET_STATE = 'UPDATE_LABELS_SET_STATE'; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js index 2e0a57f15dd..8853dc8b9e3 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js @@ -34,15 +34,12 @@ export default { // Iterate over every label and add a `set` prop // to determine whether it is already a part of // selectedLabels array. - const selectedLabelIds = state.selectedLabels.map((label) => label.id); state.labelsFetchInProgress = false; - state.labels = labels.reduce((allLabels, label) => { - allLabels.push({ - ...label, - set: selectedLabelIds.includes(label.id), - }); - return allLabels; - }, []); + state.labelsFetched = true; + state.labels = labels.map((label) => ({ + ...label, + set: state.selectedLabels.some((selectedLabel) => selectedLabel.id === label.id), + })); }, [types.RECEIVE_SET_LABELS_FAILURE](state) { state.labelsFetchInProgress = false; @@ -79,4 +76,11 @@ export default { } } }, + + [types.UPDATE_LABELS_SET_STATE](state) { + state.labels = state.labels.map((label) => ({ + ...label, + set: state.selectedLabels.some((selectedLabel) => selectedLabel.id === label.id), + })); + }, }; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js index d66cfed4163..0185d5f88e1 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js @@ -1,6 +1,7 @@ export default () => ({ // Initial Data labels: [], + labelsFetched: false, selectedLabels: [], labelsListTitle: '', labelsCreateTitle: '', diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue index 1f0704f7308..6694e349b6e 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue @@ -21,9 +21,29 @@ export default { type: String, required: true, }, + selectedLabels: { + type: Array, + required: true, + }, + allowMultiselect: { + type: Boolean, + required: true, + }, + labelsListTitle: { + type: String, + required: true, + }, + footerCreateLabelTitle: { + type: String, + required: true, + }, + footerManageLabelTitle: { + type: String, + required: true, + }, }, computed: { - ...mapState(['showDropdownContentsCreateView', 'labelsListTitle']), + ...mapState(['showDropdownContentsCreateView']), ...mapGetters(['isDropdownVariantSidebar', 'isDropdownVariantEmbedded']), dropdownContentsView() { if (this.showDropdownContentsCreateView) { @@ -75,6 +95,16 @@ export default { @click="toggleDropdownContents" /> </div> - <component :is="dropdownContentsView" @hideCreateView="toggleDropdownContentsCreateView" /> + <component + :is="dropdownContentsView" + :selected-labels="selectedLabels" + :allow-multiselect="allowMultiselect" + :labels-list-title="labelsListTitle" + :footer-create-label-title="footerCreateLabelTitle" + :footer-manage-label-title="footerManageLabelTitle" + @hideCreateView="toggleDropdownContentsCreateView" + @closeDropdown="$emit('closeDropdown', $event)" + @toggleDropdownContentsCreateView="toggleDropdownContentsCreateView" + /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue index bff34743344..ffa37424c2c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue @@ -1,38 +1,91 @@ <script> -import { GlIntersectionObserver, GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui'; +import { GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; -import { mapState, mapGetters, mapActions } from 'vuex'; - +import { debounce } from 'lodash'; +import createFlash from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; - +import { __ } from '~/locale'; +import { DropdownVariant } from './constants'; +import projectLabelsQuery from './graphql/project_labels.query.graphql'; import LabelItem from './label_item.vue'; export default { components: { - GlIntersectionObserver, GlLoadingIcon, GlSearchBoxByType, GlLink, LabelItem, }, + inject: ['projectPath', 'allowLabelCreate', 'labelsManagePath', 'variant'], + props: { + selectedLabels: { + type: Array, + required: true, + }, + allowMultiselect: { + type: Boolean, + required: true, + }, + labelsListTitle: { + type: String, + required: true, + }, + footerCreateLabelTitle: { + type: String, + required: true, + }, + footerManageLabelTitle: { + type: String, + required: true, + }, + }, data() { return { searchKey: '', + labels: [], currentHighlightItem: -1, + localSelectedLabels: [...this.selectedLabels], }; }, + apollo: { + labels: { + query: projectLabelsQuery, + variables() { + return { + fullPath: this.projectPath, + searchTerm: this.searchKey, + }; + }, + skip() { + return this.searchKey.length === 1; + }, + update: (data) => data.workspace?.labels?.nodes || [], + async result() { + if (this.$refs.searchInput) { + await this.$nextTick(); + this.$refs.searchInput.focusInput(); + } + }, + error() { + createFlash({ message: __('Error fetching labels.') }); + }, + }, + }, computed: { - ...mapState([ - 'allowLabelCreate', - 'allowMultiselect', - 'labelsManagePath', - 'labels', - 'labelsFetchInProgress', - 'labelsListTitle', - 'footerCreateLabelTitle', - 'footerManageLabelTitle', - ]), - ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar', 'isDropdownVariantEmbedded']), + isDropdownVariantSidebar() { + return this.variant === DropdownVariant.Sidebar; + }, + isDropdownVariantEmbedded() { + return this.variant === DropdownVariant.Embedded; + }, + labelsFetchInProgress() { + return this.$apollo.queries.labels.loading; + }, + localSelectedLabelsIds() { + return this.localSelectedLabels.map((label) => label.id); + }, visibleLabels() { if (this.searchKey) { return fuzzaldrinPlus.filter(this.labels, this.searchKey, { @@ -55,17 +108,16 @@ export default { } }, }, + created() { + this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + beforeDestroy() { + this.$emit('closeDropdown', this.localSelectedLabels); + this.debouncedSearchKeyUpdate.cancel(); + }, methods: { - ...mapActions([ - 'toggleDropdownContents', - 'toggleDropdownContentsCreateView', - 'fetchLabels', - 'receiveLabelsSuccess', - 'updateSelectedLabels', - 'toggleDropdownContents', - ]), isLabelSelected(label) { - return this.selectedLabelsList.includes(label.id); + return this.localSelectedLabelsIds.includes(getIdFromGraphQLId(label.id)); }, /** * This method scrolls item from dropdown into @@ -86,23 +138,17 @@ export default { } } }, - handleComponentAppear() { - // We can avoid putting `catch` block here - // as failure is handled within actions.js already. - return this.fetchLabels().then(() => { - this.$refs.searchInput.focusInput(); - }); - }, - /** - * We want to remove loaded labels to ensure component - * fetches fresh set of labels every time when shown. - */ - handleComponentDisappear() { - this.receiveLabelsSuccess([]); - }, - handleCreateLabelClick() { - this.receiveLabelsSuccess([]); - this.toggleDropdownContentsCreateView(); + updateSelectedLabels(label) { + if (this.isLabelSelected(label)) { + this.localSelectedLabels = this.localSelectedLabels.filter( + ({ id }) => id !== getIdFromGraphQLId(label.id), + ); + } else { + this.localSelectedLabels.push({ + ...label, + id: getIdFromGraphQLId(label.id), + }); + } }, /** * This method enables keyboard navigation support for @@ -117,10 +163,10 @@ export default { ) { this.currentHighlightItem += 1; } else if (e.keyCode === ENTER_KEY_CODE && this.currentHighlightItem > -1) { - this.updateSelectedLabels([this.visibleLabels[this.currentHighlightItem]]); + this.updateSelectedLabels(this.visibleLabels[this.currentHighlightItem]); this.searchKey = ''; } else if (e.keyCode === ESC_KEY_CODE) { - this.toggleDropdownContents(); + this.$emit('closeDropdown', this.localSelectedLabels); } if (e.keyCode !== ESC_KEY_CODE) { @@ -132,68 +178,82 @@ export default { } }, handleLabelClick(label) { - this.updateSelectedLabels([label]); - if (!this.allowMultiselect) this.toggleDropdownContents(); + this.updateSelectedLabels(label); + if (!this.allowMultiselect) { + this.$emit('closeDropdown', this.localSelectedLabels); + } + }, + setSearchKey(value) { + this.searchKey = value; }, }, }; </script> <template> - <gl-intersection-observer @appear="handleComponentAppear" @disappear="handleComponentDisappear"> - <div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown"> - <div class="dropdown-input" @click.stop="() => {}"> - <gl-search-box-by-type - ref="searchInput" - v-model="searchKey" - :disabled="labelsFetchInProgress" - data-qa-selector="dropdown_input_field" - /> - </div> - <div ref="labelsListContainer" class="dropdown-content" data-testid="dropdown-content"> - <gl-loading-icon - v-if="labelsFetchInProgress" - class="labels-fetch-loading gl-align-items-center w-100 h-100" - size="md" + <div + class="labels-select-contents-list js-labels-list" + data-testid="dropdown-wrapper" + @keydown="handleKeyDown" + > + <div class="dropdown-input" @click.stop="() => {}"> + <gl-search-box-by-type + ref="searchInput" + :value="searchKey" + :disabled="labelsFetchInProgress" + data-qa-selector="dropdown_input_field" + data-testid="dropdown-input-field" + @input="debouncedSearchKeyUpdate" + /> + </div> + <div ref="labelsListContainer" class="dropdown-content" data-testid="dropdown-content"> + <gl-loading-icon + v-if="labelsFetchInProgress" + class="labels-fetch-loading gl-align-items-center gl-w-full gl-h-full" + size="md" + /> + <ul v-else class="list-unstyled gl-mb-0 gl-word-break-word" data-testid="labels-list"> + <label-item + v-for="(label, index) in visibleLabels" + :key="label.id" + :label="label" + :is-label-set="isLabelSelected(label)" + :highlight="index === currentHighlightItem" + @clickLabel="handleLabelClick(label)" /> - <ul v-else class="list-unstyled gl-mb-0 gl-word-break-word"> - <label-item - v-for="(label, index) in visibleLabels" - :key="label.id" - :label="label" - :is-label-set="label.set" - :highlight="index === currentHighlightItem" - @clickLabel="handleLabelClick(label)" - /> - <li v-show="showNoMatchingResultsMessage" class="gl-p-3 gl-text-center"> - {{ __('No matching results') }} - </li> - </ul> - </div> - <div - v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" - class="dropdown-footer" - data-testid="dropdown-footer" - > - <ul class="list-unstyled"> - <li v-if="allowLabelCreate"> - <gl-link - class="gl-display-flex w-100 flex-row text-break-word label-item" - @click="handleCreateLabelClick" - > - {{ footerCreateLabelTitle }} - </gl-link> - </li> - <li> - <gl-link - :href="labelsManagePath" - class="gl-display-flex flex-row text-break-word label-item" - > - {{ footerManageLabelTitle }} - </gl-link> - </li> - </ul> - </div> + <li + v-show="showNoMatchingResultsMessage" + class="gl-p-3 gl-text-center" + data-testid="no-results" + > + {{ __('No matching results') }} + </li> + </ul> + </div> + <div + v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" + class="dropdown-footer" + data-testid="dropdown-footer" + > + <ul class="list-unstyled"> + <li v-if="allowLabelCreate"> + <gl-link + class="gl-display-flex gl-flex-direction-row gl-w-full gl-overflow-break-word label-item" + data-testid="create-label-button" + @click="$emit('toggleDropdownContentsCreateView')" + > + {{ footerCreateLabelTitle }} + </gl-link> + </li> + <li> + <gl-link + :href="labelsManagePath" + class="gl-display-flex gl-flex-direction-row gl-w-full gl-overflow-break-word label-item" + > + {{ footerManageLabelTitle }} + </gl-link> + </li> + </ul> </div> - </gl-intersection-observer> + </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue index b6d14965cfa..46edfa1c42a 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue @@ -28,8 +28,9 @@ export default { <template v-if="allowLabelEdit"> <gl-loading-icon v-show="labelsSelectInProgress" size="sm" inline /> <gl-button - variant="link" - class="float-right js-sidebar-dropdown-toggle" + category="tertiary" + size="small" + class="float-right js-sidebar-dropdown-toggle gl-mr-n2" data-qa-selector="labels_edit_button" @click="toggleDropdownContents" >{{ __('Edit') }}</gl-button diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql new file mode 100644 index 00000000000..dc39220487d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql @@ -0,0 +1,12 @@ +query projectLabels($fullPath: ID!, $searchTerm: String) { + workspace: project(fullPath: $fullPath) { + labels(searchTerm: $searchTerm, includeAncestorGroups: true) { + nodes { + id + title + color + description + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue index 87f36a5bb72..0499dfe468f 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue @@ -197,23 +197,6 @@ export default { methods: { ...mapActions(['setInitialState', 'toggleDropdownContents']), /** - * This method differentiates between - * dispatched actions and calls necessary method. - */ - handleVuexActionDispatch(action, state) { - if ( - action.type === 'toggleDropdownContents' && - !state.showDropdownButton && - !state.showDropdownContents - ) { - let filterFn = (label) => label.touched; - if (this.isDropdownVariantEmbedded) { - filterFn = (label) => label.set; - } - this.handleDropdownClose(state.labels.filter(filterFn)); - } - }, - /** * This method stores a mousedown event's target. * Required by the click listener because the click * event itself has no reference to this element. @@ -276,6 +259,9 @@ export default { handleDropdownClose(labels) { // Only emit label updates if there are any labels to update // on UI. + if (this.showDropdownContents) { + this.toggleDropdownContents(); + } if (labels.length) this.$emit('updateSelectedLabels', labels); this.$emit('onDropdownClose'); }, @@ -330,10 +316,16 @@ export default { </dropdown-value> <dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" /> <dropdown-contents - v-show="dropdownButtonVisible && showDropdownContents" + v-if="dropdownButtonVisible && showDropdownContents" ref="dropdownContents" + :allow-multiselect="allowMultiselect" + :labels-list-title="labelsListTitle" + :footer-create-label-title="footerCreateLabelTitle" + :footer-manage-label-title="footerManageLabelTitle" :render-on-top="!contentIsOnViewport" :labels-create-title="labelsCreateTitle" + :selected-labels="selectedLabels" + @closeDropdown="handleDropdownClose" /> </template> <template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded"> @@ -341,7 +333,13 @@ export default { <dropdown-contents v-if="dropdownButtonVisible && showDropdownContents" ref="dropdownContents" + :allow-multiselect="allowMultiselect" + :labels-list-title="labelsListTitle" + :footer-create-label-title="footerCreateLabelTitle" + :footer-manage-label-title="footerManageLabelTitle" :render-on-top="!contentIsOnViewport" + :selected-labels="selectedLabels" + @closeDropdown="handleDropdownClose" /> </template> </div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js index 935f020f559..b3d4a204a81 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js @@ -1,6 +1,3 @@ -import createFlash from '~/flash'; -import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; import * as types from './mutation_types'; export const setInitialState = ({ commit }, props) => commit(types.SET_INITIAL_STATE, props); @@ -11,24 +8,5 @@ export const toggleDropdownContents = ({ commit }) => commit(types.TOGGLE_DROPDO export const toggleDropdownContentsCreateView = ({ commit }) => commit(types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW); -export const requestLabels = ({ commit }) => commit(types.REQUEST_LABELS); -export const receiveLabelsSuccess = ({ commit }, labels) => - commit(types.RECEIVE_SET_LABELS_SUCCESS, labels); -export const receiveLabelsFailure = ({ commit }) => { - commit(types.RECEIVE_SET_LABELS_FAILURE); - createFlash({ - message: __('Error fetching labels.'), - }); -}; -export const fetchLabels = ({ state, dispatch }) => { - dispatch('requestLabels'); - return axios - .get(state.labelsFetchPath) - .then(({ data }) => { - dispatch('receiveLabelsSuccess', data); - }) - .catch(() => dispatch('receiveLabelsFailure')); -}; - export const updateSelectedLabels = ({ commit }, labels) => commit(types.UPDATE_SELECTED_LABELS, { labels }); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js index b8da7a90b36..bd71c3b85f1 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js @@ -1,13 +1,5 @@ export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; -export const REQUEST_LABELS = 'REQUEST_LABELS'; -export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS'; -export const RECEIVE_LABELS_FAILURE = 'RECEIVE_LABELS_FAILURE'; - -export const REQUEST_SET_LABELS = 'REQUEST_SET_LABELS'; -export const RECEIVE_SET_LABELS_SUCCESS = 'RECEIVE_SET_LABELS_SUCCESS'; -export const RECEIVE_SET_LABELS_FAILURE = 'RECEIVE_SET_LABELS_FAILURE'; - export const TOGGLE_DROPDOWN_BUTTON = 'TOGGLE_DROPDOWN_VISIBILITY'; export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS'; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js index 1c03d95f37b..45ec4d7ae04 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js @@ -26,27 +26,6 @@ export default { [types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state) { state.showDropdownContentsCreateView = !state.showDropdownContentsCreateView; }, - - [types.REQUEST_LABELS](state) { - state.labelsFetchInProgress = true; - }, - [types.RECEIVE_SET_LABELS_SUCCESS](state, labels) { - // Iterate over every label and add a `set` prop - // to determine whether it is already a part of - // selectedLabels array. - const selectedLabelIds = state.selectedLabels.map((label) => label.id); - state.labelsFetchInProgress = false; - state.labels = labels.reduce((allLabels, label) => { - allLabels.push({ - ...label, - set: selectedLabelIds.includes(label.id), - }); - return allLabels; - }, []); - }, - [types.RECEIVE_SET_LABELS_FAILURE](state) { - state.labelsFetchInProgress = false; - }, [types.UPDATE_SELECTED_LABELS](state, { labels }) { // Find the label to update from all the labels // and change `set` prop value to represent their current state. diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue index e6229cf0a93..cdc7422c7df 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue @@ -1,6 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; -import { todoLabel } from './utils'; +import { todoLabel, updateGlobalTodoCount } from './utils'; export default { components: { @@ -19,23 +19,11 @@ export default { }, }, methods: { - updateGlobalTodoCount(additionalTodoCount) { - const countContainer = document.querySelector('.js-todos-count'); - if (countContainer === null) return; - const currentCount = parseInt(countContainer.innerText, 10); - const todoToggleEvent = new CustomEvent('todo:toggle', { - detail: { - count: Math.max(currentCount + additionalTodoCount, 0), - }, - }); - - document.dispatchEvent(todoToggleEvent); - }, incrementGlobalTodoCount() { - this.updateGlobalTodoCount(1); + updateGlobalTodoCount(1); }, decrementGlobalTodoCount() { - this.updateGlobalTodoCount(-1); + updateGlobalTodoCount(-1); }, onToggle(event) { if (this.isTodo) { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js index 59e72a2ffe3..098ab72dfb5 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js @@ -3,3 +3,19 @@ import { __ } from '~/locale'; export const todoLabel = (hasTodo) => { return hasTodo ? __('Mark as done') : __('Add a to do'); }; + +export const updateGlobalTodoCount = (additionalTodoCount) => { + const countContainer = document.querySelector('.js-todos-count'); + + if (countContainer === null) return; + + const currentCount = parseInt(countContainer.innerText, 10); + + const todoToggleEvent = new CustomEvent('todo:toggle', { + detail: { + count: Math.max(currentCount + additionalTodoCount, 0), + }, + }); + + document.dispatchEvent(todoToggleEvent); +}; diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue index 55e2a786c8f..04423aac651 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue @@ -30,6 +30,11 @@ export default { GlTooltip: GlTooltipDirective, }, props: { + lazy: { + type: Boolean, + required: false, + default: false, + }, linkHref: { type: String, required: false, @@ -91,6 +96,7 @@ export default { :size="imgSize" :tooltip-text="avatarTooltipText" :tooltip-placement="tooltipPlacement" + :lazy="lazy" > <slot></slot> </user-avatar-image ><span diff --git a/app/assets/javascripts/vue_shared/components/user_date.vue b/app/assets/javascripts/vue_shared/components/user_date.vue index 38dddbf72c2..33531cc3278 100644 --- a/app/assets/javascripts/vue_shared/components/user_date.vue +++ b/app/assets/javascripts/vue_shared/components/user_date.vue @@ -1,7 +1,7 @@ <script> import { formatDate } from '~/lib/utils/datetime_utility'; import { __ } from '~/locale'; -import { SHORT_DATE_FORMAT } from '../constants'; +import { SHORT_DATE_FORMAT, DATE_FORMATS } from '../constants'; export default { props: { @@ -10,6 +10,12 @@ export default { required: false, default: null, }, + dateFormat: { + type: String, + required: false, + default: SHORT_DATE_FORMAT, + validator: (dateFormat) => DATE_FORMATS.includes(dateFormat), + }, }, computed: { formattedDate() { @@ -17,7 +23,7 @@ export default { if (date === null) { return __('Never'); } - return formatDate(new Date(date), SHORT_DATE_FORMAT); + return formatDate(new Date(date), this.dateFormat); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue index 5ba7c107c12..df0981aea7a 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -59,11 +59,21 @@ export default { required: false, default: '', }, + webIdeText: { + type: String, + required: false, + default: '', + }, gitpodUrl: { type: String, required: false, default: '', }, + gitpodText: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -99,6 +109,17 @@ export default { ...handleOptions, }; }, + webIdeActionText() { + if (this.webIdeText) { + return this.webIdeText; + } else if (this.isBlob) { + return __('Edit in Web IDE'); + } else if (this.isFork) { + return __('Edit fork in Web IDE'); + } + + return __('Web IDE'); + }, webIdeAction() { if (!this.showWebIdeButton) { return null; @@ -111,17 +132,9 @@ export default { } : { href: this.webIdeUrl }; - let text = __('Web IDE'); - - if (this.isBlob) { - text = __('Edit in Web IDE'); - } else if (this.isFork) { - text = __('Edit fork in Web IDE'); - } - return { key: KEY_WEB_IDE, - text, + text: this.webIdeActionText, secondaryText: __('Quickly and easily edit multiple files in your project.'), tooltip: '', attrs: { @@ -132,6 +145,9 @@ export default { ...handleOptions, }; }, + gitpodActionText() { + return this.gitpodText || __('Gitpod'); + }, gitpodAction() { if (!this.showGitpodButton) { return null; @@ -145,7 +161,7 @@ export default { return { key: KEY_GITPOD, - text: __('Gitpod'), + text: this.gitpodActionText, secondaryText, tooltip: secondaryText, attrs: { diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js index 9a5ad195de9..33fac5ebdbb 100644 --- a/app/assets/javascripts/vue_shared/constants.js +++ b/app/assets/javascripts/vue_shared/constants.js @@ -10,6 +10,10 @@ export const FILE_SYMLINK_MODE = '120000'; export const SHORT_DATE_FORMAT = 'd mmm, yyyy'; +export const ISO_SHORT_FORMAT = 'yyyy-mm-dd'; + +export const DATE_FORMATS = [SHORT_DATE_FORMAT, ISO_SHORT_FORMAT]; + export const timeRanges = [ { label: __('30 minutes'), diff --git a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue index 1b20ae57563..5cd2018bb8c 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue @@ -1,12 +1,12 @@ <script> import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import Vue from 'vue'; import Tracking from '~/tracking'; export default { directives: { SafeHtml, }, + mixins: [Tracking.mixin()], props: { title: { type: String, @@ -17,16 +17,6 @@ export default { required: true, }, }, - created() { - const trackingMixin = Tracking.mixin(); - const trackingInstance = new Vue({ - ...trackingMixin, - render() { - return null; - }, - }); - this.track = trackingInstance.track; - }, }; </script> <template> diff --git a/app/assets/javascripts/vue_shared/security_configuration/provider.js b/app/assets/javascripts/vue_shared/security_configuration/provider.js index ef96b443da8..fa23669b615 100644 --- a/app/assets/javascripts/vue_shared/security_configuration/provider.js +++ b/app/assets/javascripts/vue_shared/security_configuration/provider.js @@ -5,5 +5,5 @@ import createDefaultClient from '~/lib/graphql'; Vue.use(VueApollo); export default new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), }); diff --git a/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue b/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue index f3dd26b02cb..3a4453bc7ae 100644 --- a/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue +++ b/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue @@ -3,7 +3,7 @@ import { reportTypeToSecurityReportTypeEnum } from 'ee_else_ce/vue_shared/securi import createFlash from '~/flash'; import { s__ } from '~/locale'; import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue'; -import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql'; +import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql'; import { extractSecurityReportArtifactsFromMergeRequest } from '~/vue_shared/security_reports/utils'; export default { diff --git a/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue index 4178c5d1170..28618cb96a3 100644 --- a/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue +++ b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue @@ -32,6 +32,11 @@ export default { default: '', }, }, + computed: { + showDropdown() { + return this.loading || this.artifacts.length > 0; + }, + }, methods: { artifactText({ name }) { return sprintf(s__('SecurityReports|Download %{artifactName}'), { @@ -44,6 +49,7 @@ export default { <template> <gl-dropdown + v-if="showDropdown" v-gl-tooltip :text="text" :title="title" diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql new file mode 100644 index 00000000000..ae77a2ce5e4 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql @@ -0,0 +1,13 @@ +fragment JobArtifacts on Pipeline { + jobs(securityReportTypes: $reportTypes) { + nodes { + name + artifacts { + nodes { + downloadPath + fileType + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql index 4ce13827da2..4ce13827da2 100644 --- a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql +++ b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql new file mode 100644 index 00000000000..b5858ab012b --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql @@ -0,0 +1,10 @@ +#import "../fragments/job_artifacts.fragment.graphql" + +query getCorpuses($projectPath: ID!, $iid: ID, $reportTypes: [SecurityReportTypeEnum!]) { + project(fullPath: $projectPath) { + pipeline(iid: $iid) { + id + ...JobArtifacts + } + } +} diff --git a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_pipeline_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_pipeline_download_paths.query.graphql deleted file mode 100644 index c7e9fa16418..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_pipeline_download_paths.query.graphql +++ /dev/null @@ -1,18 +0,0 @@ -query getCorpuses($projectPath: ID!, $iid: ID, $reportTypes: [SecurityReportTypeEnum!]) { - project(fullPath: $projectPath) { - pipeline(iid: $iid) { - id - jobs(securityReportTypes: $reportTypes) { - nodes { - name - artifacts { - nodes { - downloadPath - fileType - } - } - } - } - } - } -} diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue index 3e0310e173e..ad40ea6a964 100644 --- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue +++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue @@ -13,7 +13,7 @@ import { REPORT_TYPE_SECRET_DETECTION, reportTypeToSecurityReportTypeEnum, } from './constants'; -import securityReportMergeRequestDownloadPathsQuery from './queries/security_report_merge_request_download_paths.query.graphql'; +import securityReportMergeRequestDownloadPathsQuery from './graphql/queries/security_report_merge_request_download_paths.query.graphql'; import store from './store'; import { MODULE_SAST, MODULE_SECRET_DETECTION } from './store/constants'; import { extractSecurityReportArtifactsFromMergeRequest } from './utils'; diff --git a/app/assets/javascripts/vue_shared/security_reports/utils.js b/app/assets/javascripts/vue_shared/security_reports/utils.js index c3f24a7e52f..0add91c402e 100644 --- a/app/assets/javascripts/vue_shared/security_reports/utils.js +++ b/app/assets/javascripts/vue_shared/security_reports/utils.js @@ -14,7 +14,7 @@ const addReportTypeIfExists = (acc, reportTypes, reportType, getName, downloadPa } }; -const extractSecurityReportArtifacts = (reportTypes, jobs) => { +export const extractSecurityReportArtifacts = (reportTypes, jobs) => { return jobs.reduce((acc, job) => { const artifacts = job.artifacts?.nodes ?? []; |