diff options
Diffstat (limited to 'app/assets')
466 files changed, 7613 insertions, 3179 deletions
diff --git a/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue b/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue new file mode 100644 index 00000000000..c716afbbcf0 --- /dev/null +++ b/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue @@ -0,0 +1,112 @@ +<script> +import { GlButton, GlDrawer, GlForm, GlFormGroup, GlFormRadioGroup } from '@gitlab/ui'; +import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; +import { s__, __ } from '~/locale'; +import csrf from '~/lib/utils/csrf'; + +export default { + name: 'AbuseCategorySelector', + csrf, + components: { + GlButton, + GlDrawer, + GlForm, + GlFormGroup, + GlFormRadioGroup, + }, + inject: { + reportAbusePath: { + default: '', + }, + reportedUserId: { + default: '', + }, + reportedFromUrl: { + default: '', + }, + }, + props: { + showDrawer: { + type: Boolean, + required: true, + }, + }, + i18n: { + title: __('Report abuse to administrator'), + close: __('Close'), + label: s__('ReportAbuse|Why are you reporting this user?'), + next: __('Next'), + }, + categoryOptions: [ + { value: 'spam', text: s__("ReportAbuse|They're posting spam.") }, + { value: 'offensive', text: s__("ReportAbuse|They're being offsensive or abusive.") }, + { value: 'phishing', text: s__("ReportAbuse|They're phising.") }, + { value: 'crypto', text: s__("ReportAbuse|They're crypto mining.") }, + { + value: 'credentials', + text: s__("ReportAbuse|They're posting personal information or credentials."), + }, + { value: 'copyright', text: s__("ReportAbuse|They're violating a copyright or trademark.") }, + { value: 'malware', text: s__("ReportAbuse|They're posting malware.") }, + { value: 'other', text: s__('ReportAbuse|Something else.') }, + ], + data() { + return { + selected: '', + }; + }, + computed: { + drawerOffsetTop() { + return getContentWrapperHeight('.content-wrapper'); + }, + }, + methods: { + closeDrawer() { + this.$emit('close-drawer'); + }, + }, +}; +</script> +<template> + <gl-drawer + :header-height="drawerOffsetTop" + :z-index="300" + :open="showDrawer" + @close="closeDrawer" + > + <template #title> + <h2 + class="gl-font-size-h2 gl-mt-0 gl-mb-0 gl-line-height-24" + data-testid="category-drawer-title" + > + {{ $options.i18n.title }} + </h2> + </template> + <template #default> + <gl-form :action="reportAbusePath" method="post" class="gl-text-left"> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + + <input type="hidden" name="user_id" :value="reportedUserId" data-testid="input-user-id" /> + <input + type="hidden" + name="abuse_report[reported_from_url]" + :value="reportedFromUrl" + data-testid="input-referer" + /> + + <gl-form-group :label="$options.i18n.label"> + <gl-form-radio-group + v-model="selected" + :options="$options.categoryOptions" + name="abuse_report[category]" + required + /> + </gl-form-group> + + <gl-button type="submit" variant="confirm" data-testid="submit-form-button"> + {{ $options.i18n.next }} + </gl-button> + </gl-form> + </template> + </gl-drawer> +</template> diff --git a/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue b/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue index 8e814cd55ef..7cc4a0d349d 100644 --- a/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue +++ b/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue @@ -35,7 +35,7 @@ export default { </script> <template> - <div class="gl-display-flex gl-align-items-center" data-testid="database-listbox"> + <div class="gl-display-flex gl-align-items-center"> <label id="label" class="gl-font-weight-bold gl-mr-4 gl-mb-0">{{ $options.i18n.database }}</label> diff --git a/app/assets/javascripts/admin/users/components/actions/ban.vue b/app/assets/javascripts/admin/users/components/actions/ban.vue index 55938832dce..898a688c203 100644 --- a/app/assets/javascripts/admin/users/components/actions/ban.vue +++ b/app/assets/javascripts/admin/users/components/actions/ban.vue @@ -11,7 +11,9 @@ const messageHtml = ` <ul> <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> + <li>${s__( + 'AdminUsers|Issues and merge requests 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/alert_handler.js b/app/assets/javascripts/alert_handler.js index 3c867f196d6..9d53101fb22 100644 --- a/app/assets/javascripts/alert_handler.js +++ b/app/assets/javascripts/alert_handler.js @@ -2,10 +2,11 @@ // Note: This ONLY works on elements that are created on page load // You can follow this effort in the following epic // https://gitlab.com/groups/gitlab-org/-/epics/4070 +import { __ } from '~/locale'; export default function initAlertHandler() { const DISMISSIBLE_SELECTORS = ['.gl-alert', '.gl-banner']; - const DISMISS_LABEL = '[aria-label="Dismiss"]'; + const DISMISS_LABEL = `[aria-label="${__('Dismiss')}"]`; const DISMISS_CLASS = '.gl-alert-dismiss'; DISMISSIBLE_SELECTORS.forEach((selector) => { diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue index 65c3bc732ed..428291f2313 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue @@ -503,6 +503,7 @@ export default { v-model="integrationForm.apiUrl" type="text" :placeholder="$options.placeholders.prometheus" + data-qa-selector="prometheus_url_field" @input="validateApiUrl" /> <span class="gl-text-gray-400"> diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue index 010cb5721a1..7dd33da435a 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue @@ -4,7 +4,7 @@ import createHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mu import updateHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql'; import { createAlert, VARIANT_SUCCESS } from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_FORBIDDEN } from '~/lib/utils/http_status'; import { typeSet, i18n, tabIndices } from '../constants'; import createPrometheusIntegrationMutation from '../graphql/mutations/create_prometheus_integration.mutation.graphql'; import destroyHttpIntegrationMutation from '../graphql/mutations/destroy_http_integration.mutation.graphql'; @@ -327,7 +327,7 @@ export default { }) .catch((error) => { let message = INTEGRATION_PAYLOAD_TEST_ERROR; - if (error.response?.status === httpStatusCodes.FORBIDDEN) { + if (error.response?.status === HTTP_STATUS_FORBIDDEN) { message = INTEGRATION_INACTIVE_PAYLOAD_TEST_ERROR; } createAlert({ message }); diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/metric_tile.vue b/app/assets/javascripts/analytics/cycle_analytics/components/metric_tile.vue deleted file mode 100644 index a5c20b237b3..00000000000 --- a/app/assets/javascripts/analytics/cycle_analytics/components/metric_tile.vue +++ /dev/null @@ -1,51 +0,0 @@ -<script> -import { GlSingleStat } from '@gitlab/ui/dist/charts'; -import { redirectTo } from '~/lib/utils/url_utility'; -import MetricPopover from '~/analytics/shared/components/metric_popover.vue'; - -export default { - name: 'MetricTile', - components: { - GlSingleStat, - MetricPopover, - }, - props: { - metric: { - type: Object, - required: true, - }, - }, - computed: { - decimalPlaces() { - const parsedFloat = parseFloat(this.metric.value); - return Number.isNaN(parsedFloat) || Number.isInteger(parsedFloat) ? 0 : 1; - }, - hasLinks() { - return this.metric.links?.length && this.metric.links[0].url; - }, - }, - methods: { - clickHandler({ links }) { - if (this.hasLinks) { - redirectTo(links[0].url); - } - }, - }, -}; -</script> -<template> - <div v-bind="$attrs"> - <gl-single-stat - :id="metric.identifier" - :value="`${metric.value}`" - :title="metric.label" - :unit="metric.unit || ''" - :should-animate="true" - :animation-decimal-places="decimalPlaces" - :class="{ 'gl-hover-cursor-pointer': hasLinks }" - tabindex="0" - @click="clickHandler(metric)" - /> - <metric-popover :metric="metric" :target="metric.identifier" /> - </div> -</template> diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js index 0f874e35684..45fddc3a696 100644 --- a/app/assets/javascripts/api/user_api.js +++ b/app/assets/javascripts/api/user_api.js @@ -65,7 +65,7 @@ export function getUserProjects(userId, query, options, callback) { export function updateUserStatus({ emoji, message, availability, clearStatusAfter }) { const url = buildApiUrl(USER_POST_STATUS_PATH); - return axios.put(url, { + return axios.patch(url, { emoji, message, availability, diff --git a/app/assets/javascripts/artifacts/components/artifact_row.vue b/app/assets/javascripts/artifacts/components/artifact_row.vue index 8c03db2acd1..fffdfce60a7 100644 --- a/app/assets/javascripts/artifacts/components/artifact_row.vue +++ b/app/assets/javascripts/artifacts/components/artifact_row.vue @@ -11,6 +11,7 @@ export default { GlBadge, GlFriendlyWrap, }, + inject: ['canDestroyArtifacts'], props: { artifact: { type: Object, @@ -73,6 +74,7 @@ export default { data-testid="job-artifact-row-download-button" /> <gl-button + v-if="canDestroyArtifacts" category="tertiary" icon="remove" :title="$options.i18n.delete" diff --git a/app/assets/javascripts/artifacts/components/feedback_banner.vue b/app/assets/javascripts/artifacts/components/feedback_banner.vue new file mode 100644 index 00000000000..d2c96b1a201 --- /dev/null +++ b/app/assets/javascripts/artifacts/components/feedback_banner.vue @@ -0,0 +1,41 @@ +<script> +import { GlBanner } from '@gitlab/ui'; +import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; +import { + I18N_FEEDBACK_BANNER_TITLE, + I18N_FEEDBACK_BANNER_BODY, + I18N_FEEDBACK_BANNER_BUTTON, + FEEDBACK_URL, +} from '../constants'; + +export default { + components: { + GlBanner, + UserCalloutDismisser, + }, + inject: ['artifactsManagementFeedbackImagePath'], + FEEDBACK_URL, + i18n: { + title: I18N_FEEDBACK_BANNER_TITLE, + body: I18N_FEEDBACK_BANNER_BODY, + button: I18N_FEEDBACK_BANNER_BUTTON, + }, +}; +</script> +<template> + <user-callout-dismisser feature-name="artifacts_management_page_feedback_banner"> + <template #default="{ dismiss, shouldShowCallout }"> + <gl-banner + v-if="shouldShowCallout" + class="gl-mb-6" + :title="$options.i18n.title" + :button-text="$options.i18n.button" + :button-link="$options.FEEDBACK_URL" + :svg-path="artifactsManagementFeedbackImagePath" + @close="dismiss" + > + <p>{{ $options.i18n.body }}</p> + </gl-banner> + </template> + </user-callout-dismisser> +</template> diff --git a/app/assets/javascripts/artifacts/components/job_artifacts_table.vue b/app/assets/javascripts/artifacts/components/job_artifacts_table.vue index 34e443f4e58..5743ff3ec9e 100644 --- a/app/assets/javascripts/artifacts/components/job_artifacts_table.vue +++ b/app/assets/javascripts/artifacts/components/job_artifacts_table.vue @@ -35,6 +35,7 @@ import { INITIAL_LAST_PAGE_SIZE, } from '../constants'; import ArtifactsTableRowDetails from './artifacts_table_row_details.vue'; +import FeedbackBanner from './feedback_banner.vue'; const INITIAL_PAGINATION_STATE = { currentPage: INITIAL_CURRENT_PAGE, @@ -58,8 +59,9 @@ export default { CiIcon, TimeAgo, ArtifactsTableRowDetails, + FeedbackBanner, }, - inject: ['projectPath'], + inject: ['projectPath', 'canDestroyArtifacts'], apollo: { jobArtifacts: { query: getJobArtifactsQuery, @@ -214,6 +216,7 @@ export default { </script> <template> <div> + <feedback-banner /> <gl-table :items="jobArtifacts" :fields="$options.fields" @@ -308,6 +311,7 @@ export default { data-testid="job-artifacts-browse-button" /> <gl-button + v-if="canDestroyArtifacts" icon="remove" :title="$options.i18n.delete" :aria-label="$options.i18n.delete" diff --git a/app/assets/javascripts/artifacts/constants.js b/app/assets/javascripts/artifacts/constants.js index 5fcc4f2b76e..28fd81fa641 100644 --- a/app/assets/javascripts/artifacts/constants.js +++ b/app/assets/javascripts/artifacts/constants.js @@ -43,6 +43,13 @@ export const I18N_MODAL_BODY = s__( export const I18N_MODAL_PRIMARY = s__('Artifacts|Delete artifact'); export const I18N_MODAL_CANCEL = __('Cancel'); +export const I18N_FEEDBACK_BANNER_TITLE = s__('Artifacts|Help us improve this page'); +export const I18N_FEEDBACK_BANNER_BODY = s__( + 'Artifacts|We want you to be able to use this page to easily manage your CI/CD job artifacts. We are working to improve this experience and would appreciate any feedback you have about the improvements we are making.', +); +export const I18N_FEEDBACK_BANNER_BUTTON = s__('Artifacts|Take a quick survey'); +export const FEEDBACK_URL = 'https://gitlab.fra1.qualtrics.com/jfe/form/SV_cI9rAUI20Vo2St8'; + export const INITIAL_CURRENT_PAGE = 1; export const INITIAL_PREVIOUS_PAGE_CURSOR = ''; export const INITIAL_NEXT_PAGE_CURSOR = ''; diff --git a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql b/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql index 9777153999e..89a24d7891e 100644 --- a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql +++ b/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql @@ -10,7 +10,6 @@ query getJobArtifacts( project(fullPath: $projectPath) { id jobs( - withArtifacts: true statuses: [SUCCESS, FAILED] first: $firstPageSize last: $lastPageSize diff --git a/app/assets/javascripts/artifacts/index.js b/app/assets/javascripts/artifacts/index.js index b5146e0f0e9..e0b2ab2bf47 100644 --- a/app/assets/javascripts/artifacts/index.js +++ b/app/assets/javascripts/artifacts/index.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; import JobArtifactsTable from './components/job_artifacts_table.vue'; Vue.use(VueApollo); @@ -16,13 +17,15 @@ export const initArtifactsTable = () => { return false; } - const { projectPath } = el.dataset; + const { projectPath, canDestroyArtifacts, artifactsManagementFeedbackImagePath } = el.dataset; return new Vue({ el, apolloProvider, provide: { projectPath, + canDestroyArtifacts: parseBoolean(canDestroyArtifacts), + artifactsManagementFeedbackImagePath, }, render: (createElement) => createElement(JobArtifactsTable), }); diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js index 5ab66acaf80..2e187eae17c 100644 --- a/app/assets/javascripts/autosave.js +++ b/app/assets/javascripts/autosave.js @@ -1,56 +1,57 @@ -/* eslint-disable no-param-reassign, consistent-return */ - +import { parseBoolean } from '~/lib/utils/common_utils'; import AccessorUtilities from './lib/utils/accessor'; export default class Autosave { constructor(field, key, fallbackKey, lockVersion) { this.field = field; - this.type = this.field.prop('type'); + this.type = this.field.getAttribute('type'); this.isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage(); - if (key.join != null) { - key = key.join('/'); - } - this.key = `autosave/${key}`; + this.key = Array.isArray(key) ? `autosave/${key.join('/')}` : `autosave/${key}`; this.fallbackKey = fallbackKey; this.lockVersionKey = `${this.key}/lockVersion`; this.lockVersion = lockVersion; - this.field.data('autosave', this); this.restore(); - this.field.on('input', () => this.save()); + this.saveAction = this.save.bind(this); + // used by app/assets/javascripts/deprecated_notes.js + this.field.$autosave = this; + this.field.addEventListener('input', this.saveAction); } restore() { if (!this.isLocalStorageAvailable) return; - if (!this.field.length) return; const text = window.localStorage.getItem(this.key); const fallbackText = window.localStorage.getItem(this.fallbackKey); + const newValue = text || fallbackText; + if (newValue == null) return; + + let originalValue = this.field.value; if (this.type === 'checkbox') { - this.field.prop('checked', text || fallbackText); - } else if (text) { - this.field.val(text); - } else if (fallbackText) { - this.field.val(fallbackText); + originalValue = this.field.checked; + this.field.checked = parseBoolean(newValue); + } else { + this.field.value = newValue; } - this.field.trigger('input'); - // v-model does not update with jQuery trigger - // https://github.com/vuejs/vue/issues/2804#issuecomment-216968137 - const event = new Event('change', { bubbles: true, cancelable: false }); - const field = this.field.get(0); - if (field) { - field.dispatchEvent(event); - } + if (originalValue === newValue) return; + this.triggerInputEvents(); + } + + triggerInputEvents() { + // trigger events so @input, @change and v-model trigger in Vue components + const inputEvent = new Event('input', { bubbles: true, cancelable: false }); + const changeEvent = new Event('change', { bubbles: true, cancelable: false }); + this.field.dispatchEvent(inputEvent); + this.field.dispatchEvent(changeEvent); } getSavedLockVersion() { - if (!this.isLocalStorageAvailable) return; + if (!this.isLocalStorageAvailable) return undefined; return window.localStorage.getItem(this.lockVersionKey); } save() { - if (!this.field.length) return; - const value = this.type === 'checkbox' ? this.field.is(':checked') : this.field.val(); + const value = this.type === 'checkbox' ? this.field.checked : this.field.value; if (this.isLocalStorageAvailable && value) { if (this.fallbackKey) { @@ -66,7 +67,7 @@ export default class Autosave { } reset() { - if (!this.isLocalStorageAvailable) return; + if (!this.isLocalStorageAvailable) return undefined; window.localStorage.removeItem(this.lockVersionKey); window.localStorage.removeItem(this.fallbackKey); @@ -74,7 +75,7 @@ export default class Autosave { } dispose() { - // eslint-disable-next-line @gitlab/no-global-event-off - this.field.off('input'); + delete this.field.$autosave; + this.field.removeEventListener('input', this.saveAction); } } diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue index acc3cbe10a0..ed0481e7a48 100644 --- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue @@ -1,5 +1,4 @@ <script> -import $ from 'jquery'; import { GlDropdown, GlButton, @@ -52,7 +51,7 @@ export default { }, mounted() { this.autosave = new Autosave( - $(this.$refs.textarea), + this.$refs.textarea, `submit_review_dropdown/${this.getNoteableData.id}`, ); this.noteData.noteable_type = this.noteableType; diff --git a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js index d712c90242c..ff301a99243 100644 --- a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js +++ b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js @@ -11,6 +11,7 @@ export default function initGFMInput($els) { emojis: true, members: enableGFM, issues: enableGFM, + iterations: enableGFM, milestones: enableGFM, mergeRequests: enableGFM, labels: enableGFM, diff --git a/app/assets/javascripts/behaviors/markdown/init_gfm.js b/app/assets/javascripts/behaviors/markdown/init_gfm.js deleted file mode 100644 index d9c7cee50da..00000000000 --- a/app/assets/javascripts/behaviors/markdown/init_gfm.js +++ /dev/null @@ -1,13 +0,0 @@ -import $ from 'jquery'; -import { renderGFM } from '~/behaviors/markdown/render_gfm'; - -$.fn.renderGFM = function plugin() { - this.get().forEach(renderGFM); - return this; -}; -requestIdleCallback( - () => { - renderGFM(document.body); - }, - { timeout: 500 }, -); diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js index 2eab5b84e3e..04b3599ea8c 100644 --- a/app/assets/javascripts/behaviors/markdown/render_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -18,6 +18,10 @@ function initPopovers(elements) { // Render GitLab flavoured Markdown export function renderGFM(element) { + if (!element) { + return; + } + const [ highlightEls, krokiEls, diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js index 86a05f24dfc..32e395e4f3c 100644 --- a/app/assets/javascripts/behaviors/preview_markdown.js +++ b/app/assets/javascripts/behaviors/preview_markdown.js @@ -1,10 +1,10 @@ /* eslint-disable func-names */ import $ from 'jquery'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; -import '~/behaviors/markdown/init_gfm'; // MarkdownPreview // @@ -51,7 +51,7 @@ MarkdownPreview.prototype.showPreview = function ($form) { } preview.removeClass('md-preview-loading').html(body); - preview.renderGFM(); + renderGFM(preview.get(0)); this.renderReferencedUsers(response.references.users, $form); if (response.references.commands) { diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcut.vue b/app/assets/javascripts/behaviors/shortcuts/shortcut.vue index e5992779a99..38384157007 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcut.vue +++ b/app/assets/javascripts/behaviors/shortcuts/shortcut.vue @@ -1,4 +1,5 @@ <script> +import { getModifierKey } from '~/constants'; import { __, s__ } from '~/locale'; // Map some keys to their proper representation depending on the system @@ -22,7 +23,7 @@ const getKeyMap = () => { keyMap.alt = keyMap.option; // Mod is Command on Mac, and Ctrl on Windows/Linux - keyMap.mod = window.gl?.client?.isMac ? keyMap.command : keyMap.ctrl; + keyMap.mod = getModifierKey(true); return keyMap; }; diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js index e0ef49b60d3..7bb6bc7e9bc 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js @@ -71,7 +71,7 @@ export default class ShortcutsNavigation extends Shortcuts { iid: window.gl.mrWidgetData?.iid, }); if (path) { - visitUrl(path); + visitUrl(path, true); } } } diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue index 361d736f740..4e47aa99fd8 100644 --- a/app/assets/javascripts/blob/components/blob_header.vue +++ b/app/assets/javascripts/blob/components/blob_header.vue @@ -66,7 +66,7 @@ export default { return !this.hideDefaultActions; }, isEmpty() { - return this.blob.rawSize === 0; + return this.blob.rawSize === '0'; }, blobSwitcherDocIcon() { return this.blob.richViewer?.fileType === 'csv' ? 'table' : 'document'; diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index adc2649e5df..2ea3c93625d 100644 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -11,7 +11,6 @@ import DockerfileSelector from './template_selectors/dockerfile_selector'; import GitignoreSelector from './template_selectors/gitignore_selector'; import LicenseSelector from './template_selectors/license_selector'; import MetricsDashboardSelector from './template_selectors/metrics_dashboard_selector'; -import FileTemplateTypeSelector from './template_selectors/type_selector'; export default class FileTemplateMediator { constructor({ editor, currentAction, projectId }) { @@ -20,7 +19,6 @@ export default class FileTemplateMediator { this.projectId = projectId; this.initTemplateSelectors(); - this.initTemplateTypeSelector(); this.initDomElements(); this.initDropdowns(); this.initPageEvents(); @@ -38,26 +36,6 @@ export default class FileTemplateMediator { ].map((TemplateSelectorClass) => new TemplateSelectorClass({ mediator: this })); } - initTemplateTypeSelector() { - this.typeSelector = new FileTemplateTypeSelector({ - mediator: this, - dropdownData: this.templateSelectors - .map((templateSelector) => { - const cfg = templateSelector.config; - - return { - name: cfg.name, - key: cfg.key, - id: cfg.key, - }; - }) - .reduce( - (acc, current) => (acc.find((item) => item.id === current.id) ? acc : [...acc, current]), - [], - ), - }); - } - initDomElements() { const $templatesMenu = $('.template-selectors-menu'); const $undoMenu = $templatesMenu.find('.template-selectors-undo-menu'); @@ -71,13 +49,10 @@ export default class FileTemplateMediator { this.$fileContent = $fileEditor.find('#file-content'); this.$commitForm = $fileEditor.find('form'); this.$navLinks = $fileEditor.find('.nav-links'); - this.$templateTypes = this.$templateSelectors.find('.template-type-selector'); } initDropdowns() { - if (this.currentAction === 'create') { - this.typeSelector.show(); - } else { + if (this.currentAction !== 'create') { this.hideTemplateSelectorMenu(); } @@ -101,32 +76,12 @@ export default class FileTemplateMediator { const hash = urlPieces[1]; if (hash === 'preview') { this.hideTemplateSelectorMenu(); - } else if (hash === 'editor' && !this.typeSelector.isHidden()) { + } else if (hash === 'editor' && this.templateSelectors.find((sel) => sel.dropdown !== null)) { this.showTemplateSelectorMenu(); } }); } - selectTemplateType(item, e) { - if (e) { - e.preventDefault(); - } - - this.templateSelectors.forEach((selector) => { - if (selector.config.key === item.key) { - selector.show(); - } else { - selector.hide(); - } - }); - this.setTypeSelectorToggleText(item.name); - this.cacheToggleText(); - } - - selectTemplateTypeOptions(options) { - this.selectTemplateType(options.selectedObj, options.e); - } - selectTemplateFile(selector, query, data) { const self = this; const { name } = selector.config; @@ -139,7 +94,7 @@ export default class FileTemplateMediator { this.setEditorContent(file); this.setFilename(name); selector.renderLoaded(); - this.typeSelector.setToggleText(name); + toast(__(`${query} template applied`), { action: { text: __('Undo'), @@ -163,15 +118,20 @@ export default class FileTemplateMediator { displayMatchedTemplateSelector() { const currentInput = this.getFilename(); - this.templateSelectors.forEach((selector) => { - const match = selector.config.pattern.test(currentInput); - - if (match) { - this.typeSelector.show(); - this.selectTemplateType(selector.config); - this.showTemplateSelectorMenu(); + const matchedSelector = this.templateSelectors.find((sel) => + sel.config.pattern.test(currentInput), + ); + const currentSelector = this.templateSelectors.find((sel) => !sel.isHidden()); + + if (matchedSelector) { + if (currentSelector) { + currentSelector.hide(); } - }); + matchedSelector.show(); + this.showTemplateSelectorMenu(); + } else { + this.hideTemplateSelectorMenu(); + } } fetchFileTemplate(type, query, data = {}) { @@ -194,16 +154,13 @@ export default class FileTemplateMediator { this.editor.navigateFileStart(); } - findTemplateSelectorByKey(key) { - return this.templateSelectors.find((selector) => selector.config.key === key); - } - hideTemplateSelectorMenu() { this.$templatesMenu.hide(); } showTemplateSelectorMenu() { this.$templatesMenu.show(); + this.cacheToggleText(); } cacheToggleText() { @@ -219,7 +176,6 @@ export default class FileTemplateMediator { this.setEditorContent(this.cachedContent); this.setFilename(this.cachedFilename); this.setTemplateSelectorToggleText(); - this.setTypeSelectorToggleText(__('Select a template type')); } getTemplateSelectorToggleText() { @@ -234,14 +190,6 @@ export default class FileTemplateMediator { .text(this.cachedToggleText); } - getTypeSelectorToggleText() { - return this.typeSelector.getToggleText(); - } - - setTypeSelectorToggleText(text) { - this.typeSelector.setToggleText(text); - } - getFilename() { return this.$filenameInput.val(); } @@ -253,8 +201,4 @@ export default class FileTemplateMediator { input.dispatchEvent(new Event('change')); } } - - getSelected() { - return this.templateSelectors.find((selector) => selector.selected); - } } diff --git a/app/assets/javascripts/blob/notebook/index.js b/app/assets/javascripts/blob/notebook/index.js index 25fe29c4fbe..9259827edf1 100644 --- a/app/assets/javascripts/blob/notebook/index.js +++ b/app/assets/javascripts/blob/notebook/index.js @@ -1,13 +1,11 @@ import Vue from 'vue'; import NotebookViewer from './notebook_viewer.vue'; -export default () => { - const el = document.getElementById('js-notebook-viewer'); - +export default ({ el = document.getElementById('js-notebook-viewer'), relativeRawPath }) => { return new Vue({ el, provide: { - relativeRawPath: el.dataset.relativeRawPath, + relativeRawPath: relativeRawPath || el.dataset.relativeRawPath, }, render(createElement) { return createElement(NotebookViewer, { diff --git a/app/assets/javascripts/blob/openapi/index.js b/app/assets/javascripts/blob/openapi/index.js index 8cfdc00bb40..2386508aef5 100644 --- a/app/assets/javascripts/blob/openapi/index.js +++ b/app/assets/javascripts/blob/openapi/index.js @@ -15,8 +15,8 @@ const createSandbox = () => { return iframeEl; }; -export default async () => { - const wrapperEl = document.getElementById('js-openapi-viewer'); +export default async (el = document.getElementById('js-openapi-viewer')) => { + const wrapperEl = el; const sandboxEl = createSandbox(); const { data } = await axios.get(wrapperEl.dataset.endpoint); diff --git a/app/assets/javascripts/blob/template_selectors/type_selector.js b/app/assets/javascripts/blob/template_selectors/type_selector.js deleted file mode 100644 index 65e7ff0594c..00000000000 --- a/app/assets/javascripts/blob/template_selectors/type_selector.js +++ /dev/null @@ -1,24 +0,0 @@ -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import FileTemplateSelector from '../file_template_selector'; - -export default class FileTemplateTypeSelector extends FileTemplateSelector { - constructor({ mediator, dropdownData }) { - super(mediator); - this.mediator = mediator; - this.config = { - dropdown: '.js-template-type-selector', - wrapper: '.js-template-type-selector-wrap', - dropdownData, - }; - } - - initDropdown() { - initDeprecatedJQueryDropdown(this.$dropdown, { - data: this.config.dropdownData, - filterable: false, - selectable: true, - clicked: (options) => this.mediator.selectTemplateTypeOptions(options), - text: (item) => item.name, - }); - } -} diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index 439c4258805..5e85e4cea38 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import '~/behaviors/markdown/init_gfm'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; import { @@ -195,7 +195,7 @@ export class BlobViewer { this.toggleCopyButtonState(); loadViewer(newViewer) .then((viewer) => { - $(viewer).renderGFM(); + renderGFM(viewer); window.requestIdleCallback(() => { this.$fileHolder.trigger('highlight:line'); handleLocationHash(); diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index 46b3f16df77..a3d11d90ed2 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext'; import { ToolbarExtension } from '~/editor/extensions/source_editor_toolbar_ext'; @@ -9,7 +10,6 @@ import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown'; import { insertFinalNewline } from '~/lib/utils/text_utility'; import TemplateSelectorMediator from '../blob/file_template_mediator'; import { BLOB_EDITOR_ERROR, BLOB_PREVIEW_ERROR } from './constants'; -import '~/behaviors/markdown/init_gfm'; export default class EditBlob { // The options object has: @@ -140,7 +140,7 @@ export default class EditBlob { }) .then(({ data }) => { currentPane.empty().append(data); - currentPane.renderGFM(); + renderGFM(currentPane.get(0)); }) .catch(() => createAlert({ diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue index 1335a3b108b..970e3509d20 100644 --- a/app/assets/javascripts/boards/components/board_app.vue +++ b/app/assets/javascripts/boards/components/board_app.vue @@ -11,7 +11,7 @@ export default { BoardSettingsSidebar, BoardTopBar, }, - inject: ['disabled', 'fullBoardId'], + inject: ['fullBoardId'], computed: { ...mapGetters(['isSidebarOpen']), }, @@ -27,7 +27,7 @@ export default { <template> <div class="boards-app gl-relative" :class="{ 'is-compact': isSidebarOpen }"> <board-top-bar /> - <board-content :disabled="disabled" :board-id="fullBoardId" /> + <board-content :board-id="fullBoardId" /> <board-settings-sidebar /> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index f3307977be9..0c64cbad5b1 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -9,6 +9,7 @@ export default { BoardCardInner, }, mixins: [Tracking.mixin()], + inject: ['disabled'], props: { list: { type: Object, @@ -20,11 +21,6 @@ export default { default: () => ({}), required: false, }, - disabled: { - type: Boolean, - default: false, - required: false, - }, index: { type: Number, default: 0, @@ -35,6 +31,11 @@ export default { default: false, required: false, }, + canAdmin: { + type: Boolean, + required: false, + default: true, + }, }, computed: { ...mapState(['selectedBoardItems', 'activeId']), @@ -48,10 +49,10 @@ export default { ); }, isDisabled() { - return this.disabled || !this.item.id || this.item.isLoading; + return this.disabled || !this.item.id || this.item.isLoading || !this.canAdmin; }, isDraggable() { - return !this.disabled && this.item.id && !this.item.isLoading; + return !this.isDisabled; }, cardStyle() { return this.isColorful && this.item.color ? { borderColor: this.item.color } : ''; diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index 05c786ca61d..77df111afc1 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -8,7 +8,7 @@ import { GlSprintf, } from '@gitlab/ui'; import { sortBy } from 'lodash'; -import { mapActions, mapGetters, mapState } from 'vuex'; +import { mapActions, mapState } from 'vuex'; import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { updateHistory } from '~/lib/utils/url_utility'; @@ -43,7 +43,7 @@ export default { GlTooltip: GlTooltipDirective, }, mixins: [boardCardInner], - inject: ['rootPath', 'scopedLabelsAvailable', 'isEpicBoard'], + inject: ['rootPath', 'scopedLabelsAvailable', 'isEpicBoard', 'issuableType', 'isGroupBoard'], props: { item: { type: Object, @@ -77,8 +77,7 @@ export default { }; }, computed: { - ...mapState(['isShowingLabels', 'issuableType', 'allowSubEpics']), - ...mapGetters(['isProjectBoard']), + ...mapState(['isShowingLabels', 'allowSubEpics']), cappedAssignees() { // e.g. maxRender is 4, // Render up to all 4 assignees if there are only 4 assigness @@ -158,7 +157,7 @@ export default { return Math.round((this.item.descendantWeightSum.closedIssues / this.totalWeight) * 100); }, showReferencePath() { - return !this.isProjectBoard && this.itemReferencePath; + return this.isGroupBoard && this.itemReferencePath; }, avatarSize() { return { default: 16, lg: 24 }; diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index 8fc76c02e14..b728b8dd22a 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -20,10 +20,6 @@ export default { default: () => ({}), required: false, }, - disabled: { - type: Boolean, - required: true, - }, }, computed: { ...mapState(['filterParams', 'highlightedLists']), @@ -87,8 +83,8 @@ export default { class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-gray-50" :class="{ 'board-column-highlighted': highlighted }" > - <board-list-header :list="list" :disabled="disabled" /> - <board-list ref="board-list" :disabled="disabled" :board-items="listItems" :list="list" /> + <board-list-header :list="list" /> + <board-list ref="board-list" :board-items="listItems" :list="list" /> </div> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index ca86894ca40..92f79e61f14 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -9,7 +9,7 @@ import { s__ } from '~/locale'; import { formatBoardLists } from 'ee_else_ce/boards/boards_util'; import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue'; import { defaultSortableOptions } from '~/sortable/constants'; -import { DraggableItemTypes, BoardType, listsQuery } from 'ee_else_ce/boards/constants'; +import { DraggableItemTypes, listsQuery } from 'ee_else_ce/boards/constants'; import BoardColumn from './board_column.vue'; export default { @@ -35,13 +35,11 @@ export default { 'issuableType', 'isIssueBoard', 'isEpicBoard', + 'isGroupBoard', + 'disabled', 'isApolloBoard', ], props: { - disabled: { - type: Boolean, - required: true, - }, boardId: { type: String, required: true, @@ -89,8 +87,8 @@ export default { queryVariables() { return { ...(this.isIssueBoard && { - isGroup: this.boardType === BoardType.group, - isProject: this.boardType === BoardType.project, + isGroup: this.isGroupBoard, + isProject: !this.isGroupBoard, }), fullPath: this.fullPath, boardId: this.boardId, @@ -176,7 +174,6 @@ export default { ref="board" :list="list" :data-draggable-item-type="$options.draggableItemTypes.list" - :disabled="disabled" :class="{ 'gl-xs-display-none!': addColumnFormVisible }" /> @@ -190,7 +187,6 @@ export default { ref="swimlanes" :lists="boardListsToUse" :can-admin-list="canAdminList" - :disabled="disabled" :style="{ height: boardHeight }" /> diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index 392a73b5859..e6d1e558c37 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -6,7 +6,7 @@ import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdow import { __, sprintf } from '~/locale'; import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; -import { ISSUABLE, INCIDENT } from '~/boards/constants'; +import { BoardType, ISSUABLE, INCIDENT, issuableTypes } from '~/boards/constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; @@ -65,17 +65,22 @@ export default { canUpdate: { default: false, }, + issuableType: { + default: issuableTypes.issue, + }, + isGroupBoard: { + default: false, + }, }, inheritAttrs: false, computed: { ...mapGetters([ - 'isGroupBoard', 'isSidebarOpen', 'activeBoardItem', 'groupPathForActiveIssue', 'projectPathForActiveIssue', ]), - ...mapState(['sidebarType', 'issuableType']), + ...mapState(['sidebarType']), isIssuableSidebar() { return this.sidebarType === ISSUABLE; }, @@ -91,14 +96,17 @@ export default { fullPath() { return this.activeBoardItem?.referencePath?.split('#')[0] || ''; }, + parentType() { + return this.isGroupBoard ? BoardType.group : BoardType.project; + }, createLabelTitle() { return sprintf(__('Create %{workspace} label'), { - workspace: this.isGroupBoard ? 'group' : 'project', + workspace: this.parentType, }); }, manageLabelTitle() { return sprintf(__('Manage %{workspace} labels'), { - workspace: this.isGroupBoard ? 'group' : 'project', + workspace: this.parentType, }); }, attrWorkspacePath() { diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index 97f52f21e7f..ce86a4d3123 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -244,6 +244,13 @@ export default { }); } + if (this.filterParams['not[healthStatus]']) { + filteredSearchValue.push({ + type: TOKEN_TYPE_HEALTH, + value: { data: this.filterParams['not[healthStatus]'], operator: '!=' }, + }); + } + if (search) { filteredSearchValue.push(search); } @@ -285,6 +292,7 @@ export default { 'not[my_reaction_emoji]': this.filterParams.not.myReactionEmoji, 'not[iteration_id]': this.filterParams.not.iterationId, 'not[release_tag]': this.filterParams.not.releaseTag, + 'not[health_status]': this.filterParams.not.healthStatus, }, undefined, ); diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index fcf026bbe00..a71bde54a8f 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -1,6 +1,6 @@ <script> import { GlModal, GlAlert } from '@gitlab/ui'; -import { mapGetters, mapActions, mapState } from 'vuex'; +import { mapActions, mapState } from 'vuex'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { visitUrl, updateHistory, getParameterByName } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; @@ -51,6 +51,12 @@ export default { boardBaseUrl: { default: '', }, + isGroupBoard: { + default: false, + }, + isProjectBoard: { + default: false, + }, }, props: { canAdminBoard: { @@ -84,7 +90,6 @@ export default { }, computed: { ...mapState(['error']), - ...mapGetters(['isGroupBoard', 'isProjectBoard']), isNewForm() { return this.currentPage === formType.new; }, diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 215691c7ba2..060a708a22f 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -31,12 +31,8 @@ export default { BoardCardMoveToPosition, }, mixins: [Tracking.mixin()], - inject: ['isEpicBoard'], + inject: ['isEpicBoard', 'disabled'], props: { - disabled: { - type: Boolean, - required: true, - }, list: { type: Object, required: true, @@ -314,7 +310,6 @@ export default { :list="list" :item="item" :data-draggable-item-type="$options.draggableItemTypes.card" - :disabled="disabled" :show-work-item-type-icon="!isEpicBoard" > <!-- TODO: remove the condition when https://gitlab.com/gitlab-org/gitlab/-/issues/377862 is resolved --> diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index bfc4b52baaf..14dff8de70f 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -60,6 +60,9 @@ export default { isEpicBoard: { default: false, }, + disabled: { + default: true, + }, }, props: { list: { @@ -67,10 +70,6 @@ export default { default: () => ({}), required: false, }, - disabled: { - type: Boolean, - required: true, - }, isSwimlanesHeader: { type: Boolean, required: false, diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index 8db366e4995..8b9fafca306 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -16,7 +16,7 @@ export default { ProjectSelect, }, mixins: [BoardNewIssueMixin], - inject: ['groupId'], + inject: ['groupId', 'fullPath', 'isGroupBoard'], props: { list: { type: Object, @@ -24,8 +24,8 @@ export default { }, }, computed: { - ...mapState(['selectedProject', 'fullPath']), - ...mapGetters(['isGroupBoard', 'getBoardItemsByList']), + ...mapState(['selectedProject']), + ...mapGetters(['getBoardItemsByList']), formEventPrefix() { return toggleFormEventPrefix.issue; }, diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 4f90d77c0be..d26aeb69dd5 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -9,7 +9,7 @@ import { GlModalDirective, } from '@gitlab/ui'; import { throttle } from 'lodash'; -import { mapActions, mapGetters, mapState } from 'vuex'; +import { mapActions, mapState } from 'vuex'; import BoardForm from 'ee_else_ce/boards/components/board_form.vue'; @@ -49,6 +49,8 @@ export default { 'hasMissingBoards', 'scopedIssueBoardFeatureEnabled', 'weights', + 'boardType', + 'isGroupBoard', ], props: { throttleDuration: { @@ -74,8 +76,7 @@ export default { }, computed: { - ...mapState(['boardType', 'board', 'isBoardLoading']), - ...mapGetters(['isGroupBoard', 'isProjectBoard']), + ...mapState(['board', 'isBoardLoading']), parentType() { return this.boardType; }, 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 bc68c2e0e99..38a171e8889 100644 --- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue @@ -4,7 +4,6 @@ import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { mapActions } from 'vuex'; import { orderBy } from 'lodash'; import BoardFilteredSearch from 'ee_else_ce/boards/components/board_filtered_search.vue'; -import { BoardType } from '~/boards/constants'; import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import issueBoardFilters from '~/boards/issue_board_filters'; @@ -47,23 +46,15 @@ export default { issue: __('Issue'), }, components: { BoardFilteredSearch }, - inject: ['isSignedIn', 'releasesFetchPath', 'fullPath', 'boardType'], + inject: ['isSignedIn', 'releasesFetchPath', 'fullPath', 'isGroupBoard'], computed: { - isGroupBoard() { - return this.boardType === BoardType.group; - }, - epicsGroupPath() { - return this.isGroupBoard - ? this.fullPath - : this.fullPath.slice(0, this.fullPath.lastIndexOf('/')); - }, tokensCE() { const { issue, incident } = this.$options.i18n; const { types } = this.$options; const { fetchUsers, fetchLabels } = issueBoardFilters( this.$apollo, this.fullPath, - this.boardType, + this.isGroupBoard, ); const tokens = [ diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index f8bd81e6b98..968832a092d 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -3,7 +3,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import BoardApp from '~/boards/components/board_app.vue'; import '~/boards/filters/due_date_filters'; -import { issuableTypes } from '~/boards/constants'; +import { BoardType, issuableTypes } from '~/boards/constants'; import store from '~/boards/stores'; import { NavigationType, @@ -31,17 +31,19 @@ function mountBoardApp(el) { ...convertObjectPropsToCamelCase(rawFilterParams), }; + const boardType = el.dataset.parent; + store.dispatch('fetchBoard', { fullPath, fullBoardId: fullBoardId(boardId), - boardType: el.dataset.parent, + boardType, }); store.dispatch('setInitialBoardData', { boardId, fullBoardId: fullBoardId(boardId), fullPath, - boardType: el.dataset.parent, + boardType, disabled: parseBoolean(el.dataset.disabled) || true, issuableType: issuableTypes.issue, }); @@ -61,7 +63,9 @@ function mountBoardApp(el) { fullPath, initialFilterParams, boardBaseUrl: el.dataset.boardBaseUrl, - boardType: el.dataset.parent, + boardType, + isGroupBoard: boardType === BoardType.group, + isProjectBoard: boardType === BoardType.project, currentUserId: gon.current_user_id || null, boardWeight: el.dataset.boardWeight ? parseInt(el.dataset.boardWeight, 10) : null, labelsManagePath: el.dataset.labelsManagePath, diff --git a/app/assets/javascripts/boards/issue_board_filters.js b/app/assets/javascripts/boards/issue_board_filters.js index 4bfd92fb748..7e9b68778d5 100644 --- a/app/assets/javascripts/boards/issue_board_filters.js +++ b/app/assets/javascripts/boards/issue_board_filters.js @@ -1,11 +1,8 @@ import groupBoardMembers from '~/boards/graphql/group_board_members.query.graphql'; import projectBoardMembers from '~/boards/graphql/project_board_members.query.graphql'; -import { BoardType } from './constants'; import boardLabels from './graphql/board_labels.query.graphql'; -export default function issueBoardFilters(apollo, fullPath, boardType) { - const isGroupBoard = boardType === BoardType.group; - const isProjectBoard = boardType === BoardType.project; +export default function issueBoardFilters(apollo, fullPath, isGroupBoard) { const transformLabels = ({ data }) => { return isGroupBoard ? data.group?.labels.nodes || [] : data.project?.labels.nodes || []; }; @@ -34,7 +31,7 @@ export default function issueBoardFilters(apollo, fullPath, boardType) { fullPath, searchTerm: labelSearchTerm, isGroup: isGroupBoard, - isProject: isProjectBoard, + isProject: !isGroupBoard, }, }) .then(transformLabels); diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js index e1891a4d954..9e746f1a1b8 100644 --- a/app/assets/javascripts/boards/stores/getters.js +++ b/app/assets/javascripts/boards/stores/getters.js @@ -1,9 +1,7 @@ import { find } from 'lodash'; -import { BoardType, inactiveId, issuableTypes } from '../constants'; +import { inactiveId, issuableTypes } from '../constants'; export default { - isGroupBoard: (state) => state.boardType === BoardType.group, - isProjectBoard: (state) => state.boardType === BoardType.project, isSidebarOpen: (state) => state.activeId !== inactiveId, isSwimlanesOn: () => false, getBoardItemById: (state) => (id) => { diff --git a/app/assets/javascripts/ci_variable_list/ci_variable_list.js b/app/assets/javascripts/ci/ci_variable_list/ci_variable_list.js index 574a5e7fd99..574a5e7fd99 100644 --- a/app/assets/javascripts/ci_variable_list/ci_variable_list.js +++ b/app/assets/javascripts/ci/ci_variable_list/ci_variable_list.js diff --git a/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_admin_variables.vue index 719696f682e..719696f682e 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_admin_variables.vue diff --git a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue index c9002edc1ab..7387a490177 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue @@ -1,15 +1,14 @@ <script> -import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlSearchBoxByType } from '@gitlab/ui'; +import { GlDropdownDivider, GlDropdownItem, GlCollapsibleListbox } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import { convertEnvironmentScope } from '../utils'; export default { name: 'CiEnvironmentsDropdown', components: { - GlDropdown, - GlDropdownItem, GlDropdownDivider, - GlSearchBoxByType, + GlDropdownItem, + GlCollapsibleListbox, }, props: { environments: { @@ -24,6 +23,7 @@ export default { }, data() { return { + selectedEnvironment: '', searchTerm: '', }; }, @@ -33,9 +33,15 @@ export default { }, filteredEnvironments() { const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); - return this.environments.filter((environment) => { - return environment.toLowerCase().includes(lowerCasedSearchTerm); - }); + + return this.environments + .filter((environment) => { + return environment.toLowerCase().includes(lowerCasedSearchTerm); + }) + .map((environment) => ({ + value: environment, + text: environment, + })); }, shouldRenderCreateButton() { return this.searchTerm && !this.environments.includes(this.searchTerm); @@ -47,44 +53,29 @@ export default { methods: { selectEnvironment(selected) { this.$emit('select-environment', selected); - this.clearSearch(); - }, - convertEnvironmentScopeValue(scope) { - return convertEnvironmentScope(scope); + this.selectedEnvironment = selected; }, createEnvironmentScope() { this.$emit('create-environment-scope', this.searchTerm); this.selectEnvironment(this.searchTerm); }, - isSelected(env) { - return this.selectedEnvironmentScope === env; - }, - clearSearch() { - this.searchTerm = ''; - }, }, }; </script> <template> - <gl-dropdown :text="environmentScopeLabel" @show="clearSearch"> - <gl-search-box-by-type v-model.trim="searchTerm" data-testid="ci-environment-search" /> - <gl-dropdown-item - v-for="environment in filteredEnvironments" - :key="environment" - :is-checked="isSelected(environment)" - is-check-item - @click="selectEnvironment(environment)" - > - {{ convertEnvironmentScopeValue(environment) }} - </gl-dropdown-item> - <gl-dropdown-item v-if="!filteredEnvironments.length" ref="noMatchingResults">{{ - __('No matching results') - }}</gl-dropdown-item> - <template v-if="shouldRenderCreateButton"> + <gl-collapsible-listbox + v-model="selectedEnvironment" + searchable + :items="filteredEnvironments" + :toggle-text="environmentScopeLabel" + @search="searchTerm = $event.trim()" + @select="selectEnvironment" + > + <template v-if="shouldRenderCreateButton" #footer> <gl-dropdown-divider /> <gl-dropdown-item data-testid="create-wildcard-button" @click="createEnvironmentScope"> {{ composedCreateButtonLabel }} </gl-dropdown-item> </template> - </gl-dropdown> + </gl-collapsible-listbox> </template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue index 4466a6a8081..4466a6a8081 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue diff --git a/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue index 6326940148a..6326940148a 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_autocomplete_tokens.js index 3f25e3df305..3f25e3df305 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_autocomplete_tokens.js diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue index 00177539cdc..967125c7b0a 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue @@ -352,7 +352,6 @@ export default { </template> <ci-environments-dropdown v-if="areScopedVariablesAvailable" - class="gl-w-full" :selected-environment-scope="variable.environmentScope" :environments="joinedEnvironments" @select-environment="setEnvironmentScope" diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue index 3c6114b38ce..3c6114b38ce 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_shared.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue index 6e39bda0b07..6e39bda0b07 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_shared.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue index 345a8def49d..345a8def49d 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js index 828d0724d93..828d0724d93 100644 --- a/app/assets/javascripts/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci/ci_variable_list/constants.js diff --git a/app/assets/javascripts/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql index a28ca4eebc9..a28ca4eebc9 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql index 9208c34f154..d6f3ddf086f 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql @@ -1,4 +1,4 @@ -#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" mutation addAdminVariable($variable: CiVariable!, $endpoint: String!) { ciVariableMutation: addAdminVariable(variable: $variable, endpoint: $endpoint) @client { diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql index a79b98f5e95..c00c8fb2a26 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql @@ -1,4 +1,4 @@ -#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" mutation deleteAdminVariable($variable: CiVariable!, $endpoint: String!) { ciVariableMutation: deleteAdminVariable(variable: $variable, endpoint: $endpoint) @client { diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql index ddea753bf90..d7b7cb77291 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql @@ -1,4 +1,4 @@ -#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" mutation updateAdminVariable($variable: CiVariable!, $endpoint: String!) { ciVariableMutation: updateAdminVariable(variable: $variable, endpoint: $endpoint) @client { diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql index 45109762e80..45109762e80 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql index 24388637672..0dbb6c891fd 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql @@ -1,4 +1,4 @@ -#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" mutation addGroupVariable($variable: CiVariable!, $endpoint: String!, $fullPath: ID!, $id: ID!) { ciVariableMutation: addGroupVariable( diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql index f7c8e209ccd..b5d007237c8 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql @@ -1,4 +1,4 @@ -#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" mutation deleteGroupVariable($variable: CiVariable!, $endpoint: String!, $fullPath: ID!, $id: ID!) { ciVariableMutation: deleteGroupVariable( diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql index 757e61a5cd3..4ffc091b490 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql @@ -1,4 +1,4 @@ -#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" mutation updateGroupVariable($variable: CiVariable!, $endpoint: String!, $fullPath: ID!, $id: ID!) { ciVariableMutation: updateGroupVariable( diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql index fa315084d86..67a02be3dc1 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql @@ -1,4 +1,4 @@ -#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" mutation addProjectVariable($variable: CiVariable!, $endpoint: String!, $fullPath: ID!, $id: ID!) { ciVariableMutation: addProjectVariable( diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql index c3358cc35b9..4420404a7b4 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql @@ -1,4 +1,4 @@ -#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" mutation deleteProjectVariable( $variable: CiVariable! diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql index fde92cef4cb..107746a19e9 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql @@ -1,4 +1,4 @@ -#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" mutation updateProjectVariable( $variable: CiVariable! diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_variables.query.graphql index 900154cd24d..538502fdd3b 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_variables.query.graphql @@ -1,4 +1,4 @@ -#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" #import "~/graphql_shared/fragments/page_info.fragment.graphql" query getGroupVariables($after: String, $first: Int = 100, $fullPath: ID!) { diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/project_environments.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql index 921e0ca25b9..921e0ca25b9 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/queries/project_environments.query.graphql +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_variables.query.graphql index ee75eba7547..af0cd2d0b2c 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_variables.query.graphql @@ -1,4 +1,4 @@ -#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" #import "~/graphql_shared/fragments/page_info.fragment.graphql" query getProjectVariables($after: String, $first: Int = 100, $fullPath: ID!) { diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/variables.query.graphql index 9b255c3c182..b8dd6f5f562 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/variables.query.graphql @@ -1,4 +1,4 @@ -#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" #import "~/graphql_shared/fragments/page_info.fragment.graphql" query getVariables($after: String, $first: Int = 100) { diff --git a/app/assets/javascripts/ci_variable_list/graphql/settings.js b/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js index 02f6c226b0f..10203383ba0 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/settings.js +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js @@ -2,8 +2,8 @@ import axios from 'axios'; import { convertObjectPropsToCamelCase, convertObjectPropsToSnakeCase, -} from '../../lib/utils/common_utils'; -import { convertToGraphQLId, getIdFromGraphQLId } from '../../graphql_shared/utils'; +} from '~/lib/utils/common_utils'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { GRAPHQL_GROUP_TYPE, GRAPHQL_PROJECT_TYPE, diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci/ci_variable_list/index.js index 174a59aba42..174a59aba42 100644 --- a/app/assets/javascripts/ci_variable_list/index.js +++ b/app/assets/javascripts/ci/ci_variable_list/index.js diff --git a/app/assets/javascripts/ci_variable_list/native_form_variable_list.js b/app/assets/javascripts/ci/ci_variable_list/native_form_variable_list.js index fdbefd8c313..fdbefd8c313 100644 --- a/app/assets/javascripts/ci_variable_list/native_form_variable_list.js +++ b/app/assets/javascripts/ci/ci_variable_list/native_form_variable_list.js diff --git a/app/assets/javascripts/ci_variable_list/utils.js b/app/assets/javascripts/ci/ci_variable_list/utils.js index eeca69274ce..eeca69274ce 100644 --- a/app/assets/javascripts/ci_variable_list/utils.js +++ b/app/assets/javascripts/ci/ci_variable_list/utils.js diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue index 255e3cb31f1..891c40482d3 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue @@ -2,7 +2,6 @@ import { EDITOR_READY_EVENT } from '~/editor/constants'; import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext'; import SourceEditor from '~/vue_shared/components/source_editor.vue'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { SOURCE_EDITOR_DEBOUNCE } from '../../constants'; export default { @@ -15,7 +14,6 @@ export default { components: { SourceEditor, }, - mixins: [glFeatureFlagMixin()], inject: ['ciConfigPath'], inheritAttrs: false, methods: { @@ -23,10 +21,8 @@ export default { this.$emit('updateCiConfig', content); }, registerCiSchema({ detail: { instance } }) { - if (this.glFeatures.schemaLinting) { - instance.use({ definition: CiSchemaExtension }); - instance.registerCiSchema(); - } + instance.use({ definition: CiSchemaExtension }); + instance.registerCiSchema(); }, }, readyEvent: EDITOR_READY_EVENT, diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue index 5692627abef..5692627abef 100644 --- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue diff --git a/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue b/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue new file mode 100644 index 00000000000..060527f2662 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue @@ -0,0 +1,86 @@ +<script> +import { GlCollapsibleListbox } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import axios from '~/lib/utils/axios_utils'; +import { DEBOUNCE_REFS_SEARCH_MS } from '../constants'; +import { formatListBoxItems, searchByFullNameInListboxOptions } from '../utils/format_refs'; + +export default { + components: { + GlCollapsibleListbox, + }, + inject: ['projectRefsEndpoint'], + props: { + value: { + type: Object, + required: false, + default: () => ({}), + }, + }, + data() { + return { + isLoading: false, + searchTerm: '', + listBoxItems: [], + }; + }, + computed: { + lowerCasedSearchTerm() { + return this.searchTerm.toLowerCase(); + }, + refShortName() { + return this.value.shortName; + }, + }, + methods: { + loadRefs() { + this.isLoading = true; + + axios + .get(this.projectRefsEndpoint, { + params: { + search: this.lowerCasedSearchTerm, + }, + }) + .then(({ data }) => { + // Note: These keys are uppercase in API + const { Branches = [], Tags = [] } = data; + + this.listBoxItems = formatListBoxItems(Branches, Tags); + }) + .catch((e) => { + this.$emit('loadingError', e); + }) + .finally(() => { + this.isLoading = false; + }); + }, + debouncedLoadRefs: debounce(function debouncedLoadRefs() { + this.loadRefs(); + }, DEBOUNCE_REFS_SEARCH_MS), + setRefSelected(refFullName) { + const ref = searchByFullNameInListboxOptions(refFullName, this.listBoxItems); + this.$emit('input', ref); + }, + setSearchTerm(searchQuery) { + this.searchTerm = searchQuery?.trim(); + this.debouncedLoadRefs(); + }, + }, +}; +</script> +<template> + <gl-collapsible-listbox + class="gl-w-full gl-font-monospace" + :items="listBoxItems" + :searchable="true" + :searching="isLoading" + :search-placeholder="__('Search refs')" + :selected="value.fullName" + toggle-class="gl-flex-direction-column gl-align-items-stretch!" + :toggle-text="refShortName" + @search="setSearchTerm" + @select="setRefSelected" + @shown.once="loadRefs" + /> +</template> diff --git a/app/assets/javascripts/pipeline_new/constants.js b/app/assets/javascripts/ci/pipeline_new/constants.js index 43f7634083b..43f7634083b 100644 --- a/app/assets/javascripts/pipeline_new/constants.js +++ b/app/assets/javascripts/ci/pipeline_new/constants.js diff --git a/app/assets/javascripts/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql b/app/assets/javascripts/ci/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql index a76e8f6b95b..a76e8f6b95b 100644 --- a/app/assets/javascripts/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql +++ b/app/assets/javascripts/ci/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql diff --git a/app/assets/javascripts/pipeline_new/graphql/queries/ci_config_variables.graphql b/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql index 648cd8b66b5..648cd8b66b5 100644 --- a/app/assets/javascripts/pipeline_new/graphql/queries/ci_config_variables.graphql +++ b/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql diff --git a/app/assets/javascripts/pipeline_new/graphql/resolvers.js b/app/assets/javascripts/ci/pipeline_new/graphql/resolvers.js index 7b0f58e8cf9..7b0f58e8cf9 100644 --- a/app/assets/javascripts/pipeline_new/graphql/resolvers.js +++ b/app/assets/javascripts/ci/pipeline_new/graphql/resolvers.js diff --git a/app/assets/javascripts/pipeline_new/index.js b/app/assets/javascripts/ci/pipeline_new/index.js index 71c76aeab36..71c76aeab36 100644 --- a/app/assets/javascripts/pipeline_new/index.js +++ b/app/assets/javascripts/ci/pipeline_new/index.js diff --git a/app/assets/javascripts/pipeline_new/utils/filter_variables.js b/app/assets/javascripts/ci/pipeline_new/utils/filter_variables.js index 57ce3d13a9a..57ce3d13a9a 100644 --- a/app/assets/javascripts/pipeline_new/utils/filter_variables.js +++ b/app/assets/javascripts/ci/pipeline_new/utils/filter_variables.js diff --git a/app/assets/javascripts/ci/pipeline_new/utils/format_refs.js b/app/assets/javascripts/ci/pipeline_new/utils/format_refs.js new file mode 100644 index 00000000000..e6d26b32d47 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_new/utils/format_refs.js @@ -0,0 +1,55 @@ +import { __ } from '~/locale'; +import { BRANCH_REF_TYPE, TAG_REF_TYPE } from '../constants'; + +function convertToListBoxItems(items) { + return items.map(({ shortName, fullName }) => ({ text: shortName, value: fullName })); +} + +export function formatRefs(refs, type) { + let fullName; + + return refs.map((ref) => { + if (type === BRANCH_REF_TYPE) { + fullName = `refs/heads/${ref}`; + } else if (type === TAG_REF_TYPE) { + fullName = `refs/tags/${ref}`; + } + + return { + shortName: ref, + fullName, + }; + }); +} + +export const formatListBoxItems = (branches, tags) => { + const finalResults = []; + + if (branches.length > 0) { + finalResults.push({ + text: __('Branches'), + options: convertToListBoxItems(formatRefs(branches, BRANCH_REF_TYPE)), + }); + } + + if (tags.length > 0) { + finalResults.push({ + text: __('Tags'), + options: convertToListBoxItems(formatRefs(tags, TAG_REF_TYPE)), + }); + } + + return finalResults; +}; + +export const searchByFullNameInListboxOptions = (fullName, listBox) => { + const optionsToSearch = + listBox.length > 1 ? listBox[0].options.concat(listBox[1].options) : listBox[0]?.options; + + const foundOption = optionsToSearch.find(({ value }) => value === fullName); + + return { + shortName: foundOption.text, + fullName: foundOption.value, + }; +}; diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue index fe16cb7a92e..d03de91ea07 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue @@ -1,14 +1,25 @@ <script> -import { GlAlert, GlBadge, GlButton, GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui'; +import { + GlAlert, + GlBadge, + GlButton, + GlLoadingIcon, + GlTabs, + GlTab, + GlSprintf, + GlLink, +} from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; import { limitedCounterWithDelimiter } from '~/lib/utils/text_utility'; import { queryToObject } from '~/lib/utils/url_utility'; import deletePipelineScheduleMutation from '../graphql/mutations/delete_pipeline_schedule.mutation.graphql'; +import playPipelineScheduleMutation from '../graphql/mutations/play_pipeline_schedule.mutation.graphql'; import takeOwnershipMutation from '../graphql/mutations/take_ownership.mutation.graphql'; import getPipelineSchedulesQuery from '../graphql/queries/get_pipeline_schedules.query.graphql'; import PipelineSchedulesTable from './table/pipeline_schedules_table.vue'; import TakeOwnershipModal from './take_ownership_modal.vue'; import DeletePipelineScheduleModal from './delete_pipeline_schedule_modal.vue'; +import PipelineScheduleEmptyState from './pipeline_schedules_empty_state.vue'; export default { i18n: { @@ -16,11 +27,15 @@ export default { scheduleDeleteError: s__( 'PipelineSchedules|There was a problem deleting the pipeline schedule.', ), + schedulePlayError: s__('PipelineSchedules|There was a problem playing the pipeline schedule.'), takeOwnershipError: s__( 'PipelineSchedules|There was a problem taking ownership of the pipeline schedule.', ), newSchedule: s__('PipelineSchedules|New schedule'), deleteSuccess: s__('PipelineSchedules|Pipeline schedule successfully deleted.'), + playSuccess: s__( + 'PipelineSchedules|Successfully scheduled a pipeline to run. Go to the %{linkStart}Pipelines page%{linkEnd} for details. ', + ), }, components: { DeletePipelineScheduleModal, @@ -30,13 +45,19 @@ export default { GlLoadingIcon, GlTabs, GlTab, + GlSprintf, + GlLink, PipelineSchedulesTable, TakeOwnershipModal, + PipelineScheduleEmptyState, }, inject: { fullPath: { default: '', }, + pipelinesPath: { + default: '', + }, }, apollo: { schedules: { @@ -68,6 +89,7 @@ export default { }, scope, hasError: false, + playSuccess: false, errorMessage: '', scheduleId: null, showDeleteModal: false, @@ -185,6 +207,27 @@ export default { this.reportError(this.$options.i18n.takeOwnershipError); } }, + async playPipelineSchedule(id) { + try { + const { + data: { + pipelineSchedulePlay: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: playPipelineScheduleMutation, + variables: { id }, + }); + + if (errors.length > 0) { + throw new Error(); + } else { + this.playSuccess = true; + } + } catch { + this.playSuccess = false; + this.reportError(this.$options.i18n.schedulePlayError); + } + }, fetchPipelineSchedulesByStatus(scope) { this.scope = scope; this.$apollo.queries.schedules.refetch(); @@ -195,62 +238,72 @@ export default { <template> <div> - <gl-alert v-if="hasError" class="gl-mb-2" variant="danger" @dismiss="hasError = false"> + <gl-alert v-if="hasError" class="gl-my-3" variant="danger" @dismiss="hasError = false"> {{ errorMessage }} </gl-alert> - <template v-else> - <gl-tabs - sync-active-tab-with-query-params - query-param-name="scope" - nav-class="gl-flex-grow-1 gl-align-items-center" + <gl-alert v-if="playSuccess" class="gl-my-3" variant="info" @dismiss="playSuccess = false"> + <gl-sprintf :message="$options.i18n.playSuccess"> + <template #link="{ content }"> + <gl-link :href="pipelinesPath" class="gl-text-decoration-none!">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> + + <gl-tabs + v-if="isLoading || count > 0" + sync-active-tab-with-query-params + query-param-name="scope" + nav-class="gl-flex-grow-1 gl-align-items-center" + > + <gl-tab + v-for="tab in tabs" + :key="tab.text" + :title-link-attributes="tab.attrs" + :query-param-value="tab.scope" + @click="fetchPipelineSchedulesByStatus(tab.scope)" > - <gl-tab - v-for="tab in tabs" - :key="tab.text" - :title-link-attributes="tab.attrs" - :query-param-value="tab.scope" - @click="fetchPipelineSchedulesByStatus(tab.scope)" - > - <template #title> - <span>{{ tab.text }}</span> + <template #title> + <span>{{ tab.text }}</span> - <template v-if="tab.showBadge"> - <gl-loading-icon v-if="tab.scope === scope && isLoading" class="gl-ml-2" /> + <template v-if="tab.showBadge"> + <gl-loading-icon v-if="tab.scope === scope && isLoading" class="gl-ml-2" /> - <gl-badge v-else-if="tab.count" size="sm" class="gl-tab-counter-badge"> - {{ tab.count }} - </gl-badge> - </template> + <gl-badge v-else-if="tab.count" size="sm" class="gl-tab-counter-badge"> + {{ tab.count }} + </gl-badge> </template> + </template> - <gl-loading-icon v-if="isLoading" size="lg" /> - <pipeline-schedules-table - v-else - :schedules="schedules.list" - @showTakeOwnershipModal="setTakeOwnershipModal" - @showDeleteModal="setDeleteModal" - /> - </gl-tab> + <gl-loading-icon v-if="isLoading" size="lg" /> + <pipeline-schedules-table + v-else + :schedules="schedules.list" + @showTakeOwnershipModal="setTakeOwnershipModal" + @showDeleteModal="setDeleteModal" + @playPipelineSchedule="playPipelineSchedule" + /> + </gl-tab> - <template #tabs-end> - <gl-button variant="confirm" class="gl-ml-auto" data-testid="new-schedule-button"> - {{ $options.i18n.newSchedule }} - </gl-button> - </template> - </gl-tabs> + <template #tabs-end> + <gl-button variant="confirm" class="gl-ml-auto" data-testid="new-schedule-button"> + {{ $options.i18n.newSchedule }} + </gl-button> + </template> + </gl-tabs> + + <pipeline-schedule-empty-state v-else-if="!isLoading && count === 0" /> - <take-ownership-modal - :visible="showTakeOwnershipModal" - @takeOwnership="takeOwnership" - @hideModal="hideModal" - /> + <take-ownership-modal + :visible="showTakeOwnershipModal" + @takeOwnership="takeOwnership" + @hideModal="hideModal" + /> - <delete-pipeline-schedule-modal - :visible="showDeleteModal" - @deleteSchedule="deleteSchedule" - @hideModal="hideModal" - /> - </template> + <delete-pipeline-schedule-modal + :visible="showDeleteModal" + @deleteSchedule="deleteSchedule" + @hideModal="hideModal" + /> </div> </template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue new file mode 100644 index 00000000000..f633ba053ee --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue @@ -0,0 +1,63 @@ +<script> +import scheduleSvg from '@gitlab/svgs/dist/illustrations/schedule-md.svg'; +import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { s__ } from '~/locale'; + +export default { + i18n: { + pipelineSchedules: s__('PipelineSchedules|Pipeline schedules'), + description: s__( + 'PipelineSchedules|A scheduled pipeline starts automatically at regular intervals, like daily or weekly. The pipeline: ', + ), + learnMore: s__( + 'PipelineSchedules|Learn more in the %{linkStart}scheduled pipelines documentation.%{linkEnd}', + ), + listElements: [ + s__('PipelineSchedules|Runs for a specific branch or tag.'), + s__('PipelineSchedules|Can have custom CI/CD variables.'), + s__('PipelineSchedules|Runs with the same project permissions as the schedule owner.'), + ], + createNew: s__('PipelineSchedules|Create a new pipeline schedule'), + }, + components: { + GlEmptyState, + GlLink, + GlSprintf, + }, + computed: { + scheduleSvgPath() { + return `data:image/svg+xml;utf8,${encodeURIComponent(scheduleSvg)}`; + }, + schedulesHelpPath() { + return helpPagePath('ci/pipelines/schedules'); + }, + }, +}; +</script> +<template> + <gl-empty-state + :svg-path="scheduleSvgPath" + :primary-button-text="$options.i18n.createNew" + primary-button-link="#" + > + <template #title> + <h3> + {{ $options.i18n.pipelineSchedules }} + </h3> + </template> + <template #description> + <p class="gl-mb-0">{{ $options.i18n.description }}</p> + <ul class="gl-list-style-position-inside" data-testid="pipeline-schedules-characteristics"> + <li v-for="(el, index) in $options.i18n.listElements" :key="index">{{ el }}</li> + </ul> + <p> + <gl-sprintf :message="$options.i18n.learnMore"> + <template #link="{ content }"> + <gl-link :href="schedulesHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue index a4ef7827f73..367b1812a27 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue @@ -71,7 +71,7 @@ export default { timezone: this.cronTimezone, formCiVariables: {}, // TODO: Add the GraphQL query to help populate the predefined variables - // app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue#131 + // app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue#131 predefinedValueOptions: {}, }; }, diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue index 8656e5d3536..45b4f618e17 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue @@ -44,7 +44,14 @@ export default { <template> <div class="gl-display-flex gl-justify-content-end"> <gl-button-group> - <gl-button v-if="canPlay" v-gl-tooltip :title="$options.i18n.playTooltip" icon="play" /> + <gl-button + v-if="canPlay" + v-gl-tooltip + :title="$options.i18n.playTooltip" + icon="play" + data-testid="play-pipeline-schedule-btn" + @click="$emit('playPipelineSchedule', schedule.id)" + /> <gl-button v-if="canTakeOwnership" v-gl-tooltip diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue index 216796b357c..56461165588 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue @@ -1,9 +1,9 @@ <script> -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; export default { components: { - CiBadge, + CiBadgeLink, }, props: { schedule: { @@ -24,7 +24,11 @@ export default { <template> <div> - <ci-badge v-if="hasPipeline" :status="lastPipelineStatus" class="gl-vertical-align-middle" /> + <ci-badge-link + v-if="hasPipeline" + :status="lastPipelineStatus" + class="gl-vertical-align-middle" + /> <span v-else data-testid="pipeline-schedule-status-text"> {{ s__('PipelineSchedules|None') }} </span> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue index 1b97a35a51e..e8cfc5b29f3 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue @@ -96,6 +96,7 @@ export default { :schedule="item" @showTakeOwnershipModal="$emit('showTakeOwnershipModal', $event)" @showDeleteModal="$emit('showDeleteModal', $event)" + @playPipelineSchedule="$emit('playPipelineSchedule', $event)" /> </template> </gl-table-lite> diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/play_pipeline_schedule.mutation.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/play_pipeline_schedule.mutation.graphql new file mode 100644 index 00000000000..4892f41b93f --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/play_pipeline_schedule.mutation.graphql @@ -0,0 +1,6 @@ +mutation playPipelineSchedule($id: CiPipelineScheduleID!) { + pipelineSchedulePlay(input: { id: $id }) { + clientMutationId + errors + } +} diff --git a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js index 4c06fa321e5..8bca4f85e9f 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js +++ b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js @@ -18,7 +18,7 @@ export default () => { return false; } - const { fullPath } = containerEl.dataset; + const { fullPath, pipelinesPath } = containerEl.dataset; return new Vue({ el: containerEl, @@ -26,6 +26,7 @@ export default () => { apolloProvider, provide: { fullPath, + pipelinesPath, }, render(createElement) { return createElement(PipelineSchedules); diff --git a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue index efa7909c913..e359344ab77 100644 --- a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue +++ b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue @@ -3,7 +3,7 @@ import { GlTableLite } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { durationTimeFormatted } from '~/lib/utils/datetime_utility'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import RunnerTags from '~/ci/runner/components/runner_tags.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { tableField } from '../utils'; @@ -11,7 +11,7 @@ import LinkCell from './cells/link_cell.vue'; export default { components: { - CiBadge, + CiBadgeLink, GlTableLite, LinkCell, RunnerTags, @@ -80,7 +80,7 @@ export default { fixed > <template #cell(status)="{ item = {} }"> - <ci-badge v-if="item.detailedStatus" :status="item.detailedStatus" /> + <ci-badge-link v-if="item.detailedStatus" :status="item.detailedStatus" /> </template> <template #cell(job)="{ item = {} }"> diff --git a/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue index 2e50dc13d2d..e0a6f4b1e67 100644 --- a/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue +++ b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue @@ -23,6 +23,8 @@ export default { RunnerSingleStat, RunnerUpgradeStatusStats: () => import('ee_component/ci/runner/components/stat/runner_upgrade_status_stats.vue'), + RunnerPerformanceStat: () => + import('ee_component/ci/runner/components/stat/runner_performance_stat.vue'), }, props: { scope: { @@ -95,6 +97,8 @@ export default { :scope="scope" :variables="variables" /> + + <runner-performance-stat class="gl-px-5" /> </div> </runner-count> </template> diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql index edfc22f644b..075dbb06190 100644 --- a/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql +++ b/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql @@ -8,7 +8,7 @@ query getRunnerJobs($id: CiRunnerID!, $first: Int, $last: Int, $before: String, nodes { id detailedStatus { - # fields for `<ci-badge>` + # fields for `<ci-badge-link>` id detailsPath group diff --git a/app/assets/javascripts/ci/runner/project_runners/index.js b/app/assets/javascripts/ci/runner/project_runners/index.js new file mode 100644 index 00000000000..3be2b4a7422 --- /dev/null +++ b/app/assets/javascripts/ci/runner/project_runners/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import ProjectRunnersApp from './project_runners_app.vue'; + +export const initProjectRunners = (selector = '#js-project-runners') => { + const el = document.querySelector(selector); + + if (!el) { + return null; + } + + const { projectFullPath } = el.dataset; + + return new Vue({ + el, + render(h) { + return h(ProjectRunnersApp, { + props: { + projectFullPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/ci/runner/project_runners/project_runners_app.vue b/app/assets/javascripts/ci/runner/project_runners/project_runners_app.vue new file mode 100644 index 00000000000..c7bf5e521a1 --- /dev/null +++ b/app/assets/javascripts/ci/runner/project_runners/project_runners_app.vue @@ -0,0 +1,19 @@ +<script> +export default { + props: { + projectFullPath: { + required: true, + type: String, + }, + }, +}; +</script> +<template> + <div> + <!-- + Under development + Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/33803 + Feature rollout: https://gitlab.com/gitlab-org/gitlab/-/issues/386573 + --> + </div> +</template> diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/index.js b/app/assets/javascripts/ci_settings_pipeline_triggers/index.js index f2972133aad..3ea8e0df022 100644 --- a/app/assets/javascripts/ci_settings_pipeline_triggers/index.js +++ b/app/assets/javascripts/ci_settings_pipeline_triggers/index.js @@ -13,11 +13,6 @@ const parseJsonArray = (triggers) => { export default (containerId = 'js-ci-pipeline-triggers-list') => { const containerEl = document.getElementById(containerId); - // Note: Remove this check when FF `ci_pipeline_triggers_settings_vue_ui` is removed. - if (!containerEl) { - return null; - } - const triggers = parseJsonArray(containerEl.dataset.triggers); return new Vue({ diff --git a/app/assets/javascripts/constants.js b/app/assets/javascripts/constants.js index c56d45166a0..defc2cbe276 100644 --- a/app/assets/javascripts/constants.js +++ b/app/assets/javascripts/constants.js @@ -1,3 +1,6 @@ -import { s__ } from '~/locale'; +/* eslint-disable @gitlab/require-i18n-strings */ -export const MODIFIER_KEY = window.gl?.client?.isMac ? '⌘' : s__('KeyboardKey|Ctrl+'); +export const getModifierKey = (removeSuffix = false) => { + const winKey = `Ctrl${removeSuffix ? '' : '+'}`; + return window.gl?.client?.isMac ? '⌘' : winKey; +}; diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 53a37fc0c51..237808983ee 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -168,7 +168,12 @@ export default { class="md-area" :class="{ 'is-focused': focused }" > - <formatting-toolbar v-if="!useBottomToolbar" ref="toolbar" class="gl-border-b" /> + <formatting-toolbar + v-if="!useBottomToolbar" + ref="toolbar" + class="gl-border-b" + @enableMarkdownEditor="$emit('enableMarkdownEditor')" + /> <div class="gl-relative gl-mt-4"> <formatting-bubble-menu /> <code-block-bubble-menu /> @@ -181,7 +186,12 @@ export default { /> <loading-indicator v-if="isLoading" /> </div> - <formatting-toolbar v-if="useBottomToolbar" ref="toolbar" class="gl-border-t" /> + <formatting-toolbar + v-if="useBottomToolbar" + ref="toolbar" + class="gl-border-t" + @enableMarkdownEditor="$emit('enableMarkdownEditor')" + /> </div> </div> </content-editor-provider> diff --git a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue index 8a25ad3fd96..36ca3b8cfb6 100644 --- a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue +++ b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue @@ -1,4 +1,5 @@ <script> +import EditorModeDropdown from '~/vue_shared/components/markdown/editor_mode_dropdown.vue'; import trackUIControl from '../services/track_ui_control'; import ToolbarButton from './toolbar_button.vue'; import ToolbarImageButton from './toolbar_image_button.vue'; @@ -9,6 +10,7 @@ import ToolbarMoreDropdown from './toolbar_more_dropdown.vue'; export default { components: { + EditorModeDropdown, ToolbarButton, ToolbarTextStyleDropdown, ToolbarLinkButton, @@ -20,6 +22,11 @@ export default { trackToolbarControlExecution({ contentType, value }) { trackUIControl({ property: contentType, value }); }, + handleEditorModeChanged(mode) { + if (mode === 'markdown') { + this.$emit('enableMarkdownEditor'); + } + }, }, }; </script> @@ -101,6 +108,8 @@ export default { /> <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" /> <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" /> + + <editor-mode-dropdown class="gl-ml-auto" value="richText" @input="handleEditorModeChanged" /> </div> </template> <style> 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 2bf32a70cd1..9c1d1faca48 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,13 +1,12 @@ <script> -import { GlDropdown, GlDropdownItem, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import { GlTooltipDirective as GlTooltip, GlCollapsibleListbox } from '@gitlab/ui'; import { __ } from '~/locale'; import { TEXT_STYLE_DROPDOWN_ITEMS } from '../constants'; import EditorStateObserver from './editor_state_observer.vue'; export default { components: { - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, EditorStateObserver, }, directives: { @@ -25,15 +24,26 @@ export default { return activeItem ? activeItem.label : this.$options.i18n.placeholder; }, + listboxItems() { + return this.$options.items.map((item) => { + return { + value: item.label, + text: item.label, + }; + }); + }, }, methods: { + mapDropdownItemToCommand(dropdownItem) { + return this.$options.items.find((option) => option.label === dropdownItem); + }, updateActiveItem({ editor }) { this.activeItem = TEXT_STYLE_DROPDOWN_ITEMS.find((item) => editor.isActive(item.contentType, item.commandParams), ); }, execute(item) { - const { editorCommand, contentType, commandParams } = item; + const { editorCommand, contentType, commandParams } = this.mapDropdownItemToCommand(item); const value = commandParams?.level; if (editorCommand) { @@ -46,8 +56,8 @@ export default { this.$emit('execute', { contentType, value }); }, - isActive(item) { - return this.tiptapEditor.isActive(item.contentType, item.commandParams); + isActive(dropdownItem) { + return this.tiptapEditor.isActive(dropdownItem.contentType, dropdownItem.commandParams); }, }, items: TEXT_STYLE_DROPDOWN_ITEMS, @@ -58,25 +68,15 @@ export default { </script> <template> <editor-state-observer @transaction="updateActiveItem"> - <gl-dropdown - v-gl-tooltip="$options.i18n.placeholder" - size="small" - data-qa-selector="text_style_dropdown" + <gl-collapsible-listbox + v-gl-tooltip.hover="$options.i18n.placeholder" + :items="listboxItems" + :toggle-text="activeItemLabel" + :selected="activeItemLabel" :disabled="!activeItem" - :text="activeItemLabel" - lazy - > - <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> + :data-qa-text-style="activeItemLabel" + data-qa-selector="text_style_dropdown" + @select="execute" + /> </editor-state-observer> </template> diff --git a/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue b/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue index c6aeb6c726d..9811a0774e1 100644 --- a/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue +++ b/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue @@ -11,7 +11,7 @@ import { debounce } from 'lodash'; import axios from '~/lib/utils/axios_utils'; import { backOff } from '~/lib/utils/common_utils'; import csrf from '~/lib/utils/csrf'; -import statusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { __, s__ } from '~/locale'; import { queryTypes, formDataValidator } from '../constants'; @@ -23,7 +23,7 @@ function backOffRequest(makeRequestCallback) { return backOff((next, stop) => { makeRequestCallback() .then((resp) => { - if (resp.status === statusCodes.OK) { + if (resp.status === HTTP_STATUS_OK) { stop(resp); } else { next(); diff --git a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue index 48ab9ce0a3c..57fae608efa 100644 --- a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue +++ b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue @@ -288,7 +288,7 @@ export default { </div> </gl-form-group> <div> - <gl-button variant="success" @click="createDeployToken"> + <gl-button variant="confirm" @click="createDeployToken"> {{ $options.translations.addTokenButton }} </gl-button> </div> diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js index c090a66a69d..8019a10a042 100644 --- a/app/assets/javascripts/deprecated_notes.js +++ b/app/assets/javascripts/deprecated_notes.js @@ -15,6 +15,7 @@ import Autosize from 'autosize'; import $ from 'jquery'; import { escape, uniqueId } from 'lodash'; import Vue from 'vue'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { createAlert, VARIANT_INFO } from '~/flash'; import '~/lib/utils/jquery_at_who'; import AjaxCache from '~/lib/utils/ajax_cache'; @@ -40,7 +41,6 @@ import { localTimeAgo } from './lib/utils/datetime_utility'; import { getLocationHash } from './lib/utils/url_utility'; import { sprintf, s__, __ } from './locale'; import TaskList from './task_list'; -import '~/behaviors/markdown/init_gfm'; window.autosize = Autosize; @@ -516,7 +516,11 @@ export default class Notes { } if (discussionContainer.length === 0) { if (noteEntity.diff_discussion_html) { - const $discussion = $(noteEntity.diff_discussion_html).renderGFM(); + const discussionElement = document.createElement('table'); + // eslint-disable-next-line no-unsanitized/method + discussionElement.insertAdjacentHTML('afterbegin', noteEntity.diff_discussion_html); + renderGFM(discussionElement); + const $discussion = $(discussionElement).unwrap(); if (!this.isParallelView() || row.hasClass('js-temp-notes-holder') || noteEntity.on_image) { // insert the note and the reply button after the temp row @@ -571,7 +575,9 @@ export default class Notes { // reset text and preview form.find('.js-md-write-button').click(); form.find('.js-note-text').val('').trigger('input'); - form.find('.js-note-text').data('autosave').reset(); + form.find('.js-note-text').each(function reset() { + this.$autosave.reset(); + }); const event = document.createEvent('Event'); event.initEvent('autosize:update', true, false); @@ -638,7 +644,9 @@ export default class Notes { // DiffNote form.find('#note_position').val(), ]; - return new Autosave(textarea, key); + const textareaEl = textarea.get(0); + // eslint-disable-next-line no-new + if (textareaEl) new Autosave(textareaEl, key); } /** @@ -708,7 +716,7 @@ export default class Notes { $noteAvatar.append($targetNoteBadge); this.revertNoteEditForm($targetNote); - $noteEntityEl.renderGFM(); + renderGFM($noteEntityEl.get(0)); // Find the note's `li` element by ID and replace it with the updated HTML const $note_li = $(`.note-row-${noteEntity.id}`); @@ -1082,7 +1090,9 @@ export default class Notes { const row = form.closest('tr'); const glForm = form.data('glForm'); glForm.destroy(); - form.find('.js-note-text').data('autosave').reset(); + form.find('.js-note-text').each(function reset() { + this.$autosave.reset(); + }); // show the reply button (will only work for replies) form.prev('.discussion-reply-holder').show(); if (row.is('.js-temp-notes-holder')) { @@ -1382,7 +1392,8 @@ export default class Notes { static animateAppendNote(noteHtml, $notesList) { const $note = $(noteHtml); - $note.addClass('fade-in-full').renderGFM(); + $note.addClass('fade-in-full'); + renderGFM($note.get(0)); $notesList.append($note); return $note; } @@ -1390,7 +1401,8 @@ export default class Notes { static animateUpdateNote(noteHtml, $note) { const $updatedNote = $(noteHtml); - $updatedNote.addClass('fade-in').renderGFM(); + $updatedNote.addClass('fade-in'); + renderGFM($updatedNote.get(0)); $note.replaceWith($updatedNote); return $updatedNote; } diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue index 5a6b220e532..830f16b50ee 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue @@ -1,6 +1,5 @@ <script> import { GlButton } from '@gitlab/ui'; -import $ from 'jquery'; import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; import Autosave from '~/autosave'; @@ -118,7 +117,7 @@ export default { }, initAutosaveComment() { if (this.isLoggedIn) { - this.autosaveDiscussion = new Autosave($(this.$refs.textarea), [ + this.autosaveDiscussion = new Autosave(this.$refs.textarea, [ s__('DesignManagement|Discussion'), getIdFromGraphQLId(this.noteableId), this.shortDiscussionId, diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js index 23eb470503e..65816495432 100644 --- a/app/assets/javascripts/diff.js +++ b/app/assets/javascripts/diff.js @@ -12,9 +12,13 @@ const UNFOLD_COUNT = 20; let isBound = false; export default class Diff { - constructor() { + constructor({ mergeRequestEventHub } = {}) { const $diffFile = $('.files .diff-file'); + if (mergeRequestEventHub) { + this.mrHub = mergeRequestEventHub; + } + $diffFile.each((index, file) => { if (!$.data(file, 'singleFileDiff')) { $.data(file, 'singleFileDiff', new SingleFileDiff(file)); @@ -34,7 +38,8 @@ export default class Diff { $(document) .on('click', '.js-unfold', this.handleClickUnfold.bind(this)) .on('click', '.diff-line-num a', this.handleClickLineNum.bind(this)) - .on('mousedown', 'td.line_content.parallel', this.handleParallelLineDown.bind(this)); + .on('mousedown', 'td.line_content.parallel', this.handleParallelLineDown.bind(this)) + .on('click', '.inline-parallel-buttons a', ($e) => this.viewTypeSwitch($e)); isBound = true; } @@ -135,6 +140,20 @@ export default class Diff { diffViewType() { return $('.inline-parallel-buttons a.active').data('viewType'); } + viewTypeSwitch(event) { + const click = event.originalEvent; + const diffSource = new URL(click.target.getAttribute('href'), document.location.href); + + if (this.mrHub) { + click.preventDefault(); + click.stopPropagation(); + + diffSource.pathname = `${diffSource.pathname}.json`; + + this.mrHub.$emit('diff:switch-view-type', { source: diffSource.toString() }); + } + } + // eslint-disable-next-line class-methods-use-this lineNumbers(line) { const children = line.find('.diff-line-num').toArray(); diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 7bc75127876..35d1a564178 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -389,10 +389,7 @@ export default { () => { this.setDiscussions(); - if ( - this.$store.state.notes.doneFetchingBatchDiscussions && - window.gon?.features?.paginatedMrDiscussions - ) { + if (this.$store.state.notes.doneFetchingBatchDiscussions) { this.unwatchDiscussions(); } }, @@ -402,10 +399,6 @@ export default { () => `${this.retrievingBatches}:${this.$store.state.notes.discussions.length}`, () => { if (!this.retrievingBatches && this.$store.state.notes.discussions.length) { - if (!window.gon?.features?.paginatedMrDiscussions) { - this.unwatchDiscussions(); - } - this.unwatchRetrievingBatches(); } }, diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index dff61acdfba..16f45c3ad6a 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -427,6 +427,7 @@ export default { :href="diffFile.ide_edit_path" class="js-ide-edit-blob" data-qa-selector="edit_in_ide_button" + target="_blank" > {{ __('Open in Web IDE') }} </gl-dropdown-item> diff --git a/app/assets/javascripts/diffs/components/merge_conflict_warning.vue b/app/assets/javascripts/diffs/components/merge_conflict_warning.vue index c37a1d75650..6cb1ed4cbcf 100644 --- a/app/assets/javascripts/diffs/components/merge_conflict_warning.vue +++ b/app/assets/javascripts/diffs/components/merge_conflict_warning.vue @@ -36,7 +36,7 @@ export default { <p class="gl-mb-0"> {{ __( - 'Resolve these conflicts or ask someone with write access to this repository to merge it locally.', + 'Resolve these conflicts, or ask someone with write access to this repository to resolve them locally.', ) }} </p> @@ -54,7 +54,7 @@ export default { v-gl-modal-directive="'modal-merge-info'" class="gl-alert-action" > - {{ __('Merge locally') }} + {{ __('Resolve locally') }} </gl-button> </template> </gl-alert> diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index ffbea854001..abf77fa2ede 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -2,10 +2,13 @@ import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import micromatch from 'micromatch'; +import { getModifierKey } from '~/constants'; import { s__, sprintf } from '~/locale'; import FileTree from '~/vue_shared/components/file_tree.vue'; import DiffFileRow from './diff_file_row.vue'; +const MODIFIER_KEY = getModifierKey(); + export default { directives: { GlTooltip: GlTooltipDirective, @@ -65,8 +68,8 @@ export default { this.search = ''; }, }, - searchPlaceholder: sprintf(s__('MergeRequest|Search (e.g. *.vue) (%{modifier_key}P)'), { - modifier_key: /Mac/i.test(navigator.userAgent) ? '⌘' : 'Ctrl+', + searchPlaceholder: sprintf(s__('MergeRequest|Search (e.g. *.vue) (%{MODIFIER_KEY}P)'), { + MODIFIER_KEY, }), DiffFileRow, }; diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 96a73917820..9f90de9abde 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -9,7 +9,7 @@ import { createAlert, VARIANT_WARNING } from '~/flash'; import { diffViewerModes } from '~/ide/constants'; import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import Poll from '~/lib/utils/poll'; import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; @@ -232,7 +232,7 @@ export const fetchDiffFilesMeta = ({ commit, state }) => { .catch((error) => { worker.terminate(); - if (error.response.status === httpStatusCodes.NOT_FOUND) { + if (error.response.status === HTTP_STATUS_NOT_FOUND) { createAlert({ message: __('Building your merge request. Wait a few moments, then refresh this page.'), variant: VARIANT_WARNING, @@ -248,7 +248,7 @@ export const fetchCoverageFiles = ({ commit, state }) => { data: state.endpointCoverage, method: 'getCoverageReports', successCallback: ({ status, data }) => { - if (status === httpStatusCodes.OK) { + if (status === HTTP_STATUS_OK) { commit(types.SET_COVERAGE_DATA, data); coveragePoll.stop(); diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js index d0649ecccba..d235319dfd7 100644 --- a/app/assets/javascripts/editor/constants.js +++ b/app/assets/javascripts/editor/constants.js @@ -1,7 +1,9 @@ -import { MODIFIER_KEY } from '~/constants'; +import { getModifierKey } from '~/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { s__, __, sprintf } from '~/locale'; +const modifierKey = getModifierKey(); + export const URI_PREFIX = 'gitlab'; export const CONTENT_UPDATE_DEBOUNCE = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; @@ -67,7 +69,7 @@ export const EXTENSION_MARKDOWN_BUTTONS = [ { id: 'bold', label: sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { - modifierKey: MODIFIER_KEY, + modifierKey, }), data: { mdTag: '**', @@ -77,7 +79,7 @@ export const EXTENSION_MARKDOWN_BUTTONS = [ { id: 'italic', label: sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { - modifierKey: MODIFIER_KEY, + modifierKey, }), data: { mdTag: '_', @@ -87,7 +89,7 @@ export const EXTENSION_MARKDOWN_BUTTONS = [ { id: 'strikethrough', label: sprintf(s__('MarkdownEditor|Add strikethrough text (%{modifierKey}⇧X)'), { - modifierKey: MODIFIER_KEY, + modifierKey, }), data: { mdTag: '~~', @@ -113,7 +115,7 @@ export const EXTENSION_MARKDOWN_BUTTONS = [ { id: 'link', label: sprintf(s__('MarkdownEditor|Add a link (%{modifier_key}K)'), { - modifierKey: MODIFIER_KEY, + modifierKey, }), data: { mdTag: '[{text}](url)', diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index d94aa73e43a..87d869cc996 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -239,8 +239,15 @@ } ] }, + "browser_performance": { + "type": "string", + "description": "Path to a single file with browser performance metric report(s)." + }, "coverage_report": { - "type": "object", + "type": [ + "object", + "null" + ], "description": "Used to collect coverage reports from the job.", "properties": { "coverage_format": { @@ -292,10 +299,6 @@ "$ref": "#/definitions/string_file_list", "description": "Path to file or list of files with license report(s)." }, - "performance": { - "$ref": "#/definitions/string_file_list", - "description": "Path to file or list of files with performance metrics report(s)." - }, "requirements": { "$ref": "#/definitions/string_file_list", "description": "Path to file or list of files with requirements report(s)." @@ -703,7 +706,10 @@ } }, "rules": { - "type": "array", + "type": [ + "array", + "null" + ], "markdownDescription": "Rules allows for an array of individual rule objects to be evaluated in order, until one matches and dynamically provides attributes to the job. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#rules).", "items": { "anyOf": [ @@ -994,6 +1000,11 @@ "pull-push" ] }, + "unprotect": { + "type": "boolean", + "markdownDescription": "Use `unprotect: true` to set a cache to be shared between protected and unprotected branches.", + "default": false + }, "untracked": { "type": "boolean", "markdownDescription": "Use `untracked: true` to cache all files that are untracked in your Git repository. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#cacheuntracked)", @@ -1601,7 +1612,7 @@ "description": "Creates N instances of the same job that run in parallel.", "default": 0, "minimum": 2, - "maximum": 50 + "maximum": 200 }, { "type": "object", @@ -1620,7 +1631,7 @@ ] } }, - "maxItems": 50 + "maxItems": 200 } }, "additionalProperties": false, diff --git a/app/assets/javascripts/environments/environment_details/components/deployment_job.vue b/app/assets/javascripts/environments/environment_details/components/deployment_job.vue new file mode 100644 index 00000000000..dbe25a81550 --- /dev/null +++ b/app/assets/javascripts/environments/environment_details/components/deployment_job.vue @@ -0,0 +1,24 @@ +<script> +import { GlTruncate, GlLink, GlBadge } from '@gitlab/ui'; + +export default { + components: { + GlBadge, + GlTruncate, + GlLink, + }, + props: { + job: { + type: Object, + required: false, + default: null, + }, + }, +}; +</script> +<template> + <gl-link v-if="job" :href="job.webPath"> + <gl-truncate :text="job.label" /> + </gl-link> + <gl-badge v-else variant="info">{{ __('API') }}</gl-badge> +</template> diff --git a/app/assets/javascripts/environments/environment_details/components/deployment_status_link.vue b/app/assets/javascripts/environments/environment_details/components/deployment_status_link.vue new file mode 100644 index 00000000000..82926e2e596 --- /dev/null +++ b/app/assets/javascripts/environments/environment_details/components/deployment_status_link.vue @@ -0,0 +1,26 @@ +<script> +import DeploymentStatusBadge from '~/environments/components/deployment_status_badge.vue'; + +export default { + components: { + DeploymentStatusBadge, + }, + props: { + status: { + type: String, + required: true, + }, + deploymentJob: { + type: Object, + required: false, + default: null, + }, + }, +}; +</script> +<template> + <a v-if="deploymentJob" data-testid="deployment-status-job-link" :href="deploymentJob.webPath"> + <deployment-status-badge :status="status" /> + </a> + <deployment-status-badge v-else :status="status" /> +</template> diff --git a/app/assets/javascripts/environments/environment_details/components/deployment_triggerer.vue b/app/assets/javascripts/environments/environment_details/components/deployment_triggerer.vue new file mode 100644 index 00000000000..18ff31f9b0f --- /dev/null +++ b/app/assets/javascripts/environments/environment_details/components/deployment_triggerer.vue @@ -0,0 +1,25 @@ +<script> +import { GlAvatar, GlAvatarLink, GlTooltipDirective } from '@gitlab/ui'; + +export default { + components: { + GlAvatar, + GlAvatarLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + triggerer: { + type: Object, + required: false, + default: null, + }, + }, +}; +</script> +<template> + <gl-avatar-link v-if="triggerer" :href="triggerer.webUrl"> + <gl-avatar v-gl-tooltip :title="triggerer.name" :src="triggerer.avatarUrl" :size="24" /> + </gl-avatar-link> +</template> diff --git a/app/assets/javascripts/environments/environment_details/constants.js b/app/assets/javascripts/environments/environment_details/constants.js index 56c70c354b7..bf690ffedeb 100644 --- a/app/assets/javascripts/environments/environment_details/constants.js +++ b/app/assets/javascripts/environments/environment_details/constants.js @@ -1,4 +1,5 @@ -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; export const ENVIRONMENT_DETAILS_PAGE_SIZE = 20; export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [ @@ -45,3 +46,17 @@ export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [ tdClass: 'gl-vertical-align-middle! gl-white-space-nowrap', }, ]; + +export const translations = { + emptyStateTitle: s__("Deployments|You don't have any deployments right now."), + emptyStatePrimaryButton: __('Read more'), + emptyStateDescription: s__( + 'Deployments|Define environments in the deploy stage(s) in %{code_open}.gitlab-ci.yml%{code_close} to track deployments here.', + ), + nextPageButtonLabel: __('Next'), + previousPageButtonLabel: __('Prev'), +}; + +export const codeBlockPlaceholders = { code: ['code_open', 'code_close'] }; + +export const environmentsHelpPagePath = helpPagePath('ci/environments/index.md'); diff --git a/app/assets/javascripts/environments/environment_details/deployments_table.vue b/app/assets/javascripts/environments/environment_details/deployments_table.vue new file mode 100644 index 00000000000..41570ee44c0 --- /dev/null +++ b/app/assets/javascripts/environments/environment_details/deployments_table.vue @@ -0,0 +1,55 @@ +<script> +import { GlTableLite } from '@gitlab/ui'; +import Commit from '~/vue_shared/components/commit.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import DeploymentStatusLink from './components/deployment_status_link.vue'; +import DeploymentJob from './components/deployment_job.vue'; +import DeploymentTriggerer from './components/deployment_triggerer.vue'; +import { ENVIRONMENT_DETAILS_TABLE_FIELDS } from './constants'; + +export default { + components: { + DeploymentTriggerer, + DeploymentJob, + Commit, + TimeAgoTooltip, + DeploymentStatusLink, + GlTableLite, + }, + props: { + deployments: { + type: Array, + required: true, + }, + }, + tableFields: ENVIRONMENT_DETAILS_TABLE_FIELDS, +}; +</script> +<template> + <gl-table-lite :items="deployments" :fields="$options.tableFields" fixed stacked="lg"> + <template #table-colgroup="{ fields }"> + <col v-for="field in fields" :key="field.key" :class="field.columnClass" /> + </template> + <template #cell(status)="{ item }"> + <deployment-status-link :deployment-job="item.job" :status="item.status" /> + </template> + <template #cell(id)="{ item }"> + <strong>{{ item.id }}</strong> + </template> + <template #cell(triggerer)="{ item }"> + <deployment-triggerer :triggerer="item.triggerer" /> + </template> + <template #cell(commit)="{ item }"> + <commit v-bind="item.commit" /> + </template> + <template #cell(job)="{ item }"> + <deployment-job :job="item.job" /> + </template> + <template #cell(created)="{ item }"> + <time-ago-tooltip :time="item.created" /> + </template> + <template #cell(deployed)="{ item }"> + <time-ago-tooltip :time="item.deployed" /> + </template> + </gl-table-lite> +</template> diff --git a/app/assets/javascripts/environments/environment_details/empty_state.vue b/app/assets/javascripts/environments/environment_details/empty_state.vue new file mode 100644 index 00000000000..6f08b319408 --- /dev/null +++ b/app/assets/javascripts/environments/environment_details/empty_state.vue @@ -0,0 +1,34 @@ +<script> +import { GlEmptyState, GlSprintf } from '@gitlab/ui'; +import { translations, codeBlockPlaceholders, environmentsHelpPagePath } from './constants'; + +export default { + components: { + GlSprintf, + GlEmptyState, + }, + translations, + actionButtonUrl: environmentsHelpPagePath, + placeholders: { + code: codeBlockPlaceholders, + }, +}; +</script> +<template> + <gl-empty-state + :title="$options.translations.emptyStateTitle" + :primary-button-text="$options.translations.emptyStatePrimaryButton" + :primary-button-link="$options.actionButtonUrl" + > + <template #description> + <gl-sprintf + :message="$options.translations.emptyStateDescription" + :placeholders="$options.placeholders.code" + > + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/environments/environment_details/index.vue b/app/assets/javascripts/environments/environment_details/index.vue index 435d3fd820e..b43f4233b9c 100644 --- a/app/assets/javascripts/environments/environment_details/index.vue +++ b/app/assets/javascripts/environments/environment_details/index.vue @@ -1,36 +1,19 @@ <script> -import { - GlTableLite, - GlAvatarLink, - GlAvatar, - GlLink, - GlTooltipDirective, - GlTruncate, - GlBadge, - GlLoadingIcon, -} from '@gitlab/ui'; -import Commit from '~/vue_shared/components/commit.vue'; -import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { logError } from '~/lib/logger'; import environmentDetailsQuery from '../graphql/queries/environment_details.query.graphql'; import { convertToDeploymentTableRow } from '../helpers/deployment_data_transformation_helper'; -import DeploymentStatusBadge from '../components/deployment_status_badge.vue'; -import { ENVIRONMENT_DETAILS_PAGE_SIZE, ENVIRONMENT_DETAILS_TABLE_FIELDS } from './constants'; +import EmptyState from './empty_state.vue'; +import DeploymentsTable from './deployments_table.vue'; +import Pagination from './pagination.vue'; +import { ENVIRONMENT_DETAILS_PAGE_SIZE } from './constants'; export default { components: { + Pagination, + DeploymentsTable, + EmptyState, GlLoadingIcon, - GlBadge, - DeploymentStatusBadge, - TimeAgoTooltip, - GlTableLite, - GlAvatarLink, - GlAvatar, - GlLink, - GlTruncate, - Commit, - }, - directives: { - GlTooltip: GlTooltipDirective, }, props: { projectFullPath: { @@ -41,6 +24,16 @@ export default { type: String, required: true, }, + after: { + type: String, + required: false, + default: null, + }, + before: { + type: String, + required: false, + default: null, + }, }, apollo: { project: { @@ -49,18 +42,19 @@ export default { return { projectFullPath: this.projectFullPath, environmentName: this.environmentName, - pageSize: ENVIRONMENT_DETAILS_PAGE_SIZE, + first: this.before ? null : ENVIRONMENT_DETAILS_PAGE_SIZE, + last: this.before ? ENVIRONMENT_DETAILS_PAGE_SIZE : null, + after: this.after, + before: this.before, }; }, }, }, data() { return { - project: { - loading: true, - }, - loading: 0, - tableFields: ENVIRONMENT_DETAILS_TABLE_FIELDS, + project: {}, + isInitialPageDataReceived: false, + isPrefetchingPages: false, }; }, computed: { @@ -70,49 +64,80 @@ export default { isLoading() { return this.$apollo.queries.project.loading; }, + isDeploymentTableShown() { + return this.isInitialPageDataReceived === true && this.deployments.length > 0; + }, + pageInfo() { + return this.project.environment?.deployments.pageInfo || {}; + }, + isPaginationDisabled() { + return this.isLoading || this.isPrefetchingPages; + }, + }, + watch: { + async project(newProject) { + this.isInitialPageDataReceived = true; + this.isPrefetchingPages = true; + + try { + // TLDR: when we load a page, if there's next and/or previous pages existing, we'll load their data as well to improve percepted performance. + const { + endCursor, + hasPreviousPage, + hasNextPage, + startCursor, + } = newProject.environment.deployments.pageInfo; + + // At the moment we have a limit of deployments being requested only from a signle environment entity per query, + // and apparently two batched queries count as one on server-side + // to load both next and previous page data, we have to query them sequentially + if (hasNextPage) { + await this.$apollo.query({ + query: environmentDetailsQuery, + variables: { + projectFullPath: this.projectFullPath, + environmentName: this.environmentName, + first: ENVIRONMENT_DETAILS_PAGE_SIZE, + after: endCursor, + before: null, + last: null, + }, + }); + } + + if (hasPreviousPage) { + await this.$apollo.query({ + query: environmentDetailsQuery, + variables: { + projectFullPath: this.projectFullPath, + environmentName: this.environmentName, + first: null, + after: null, + before: startCursor, + last: ENVIRONMENT_DETAILS_PAGE_SIZE, + }, + }); + } + } catch (error) { + logError(error); + } + + this.isPrefetchingPages = false; + }, }, }; </script> <template> - <div> - <gl-loading-icon v-if="isLoading" size="lg" class="mt-3" /> - <gl-table-lite v-else :items="deployments" :fields="tableFields" fixed stacked="lg"> - <template #table-colgroup="{ fields }"> - <col v-for="field in fields" :key="field.key" :class="field.columnClass" /> - </template> - <template #cell(status)="{ item }"> - <div> - <deployment-status-badge :status="item.status" /> - </div> - </template> - <template #cell(id)="{ item }"> - <strong>{{ item.id }}</strong> - </template> - <template #cell(triggerer)="{ item }"> - <gl-avatar-link :href="item.triggerer.webUrl"> - <gl-avatar - v-gl-tooltip - :title="item.triggerer.name" - :src="item.triggerer.avatarUrl" - :size="24" - /> - </gl-avatar-link> - </template> - <template #cell(commit)="{ item }"> - <commit v-bind="item.commit" /> - </template> - <template #cell(job)="{ item }"> - <gl-link v-if="item.job" :href="item.job.webPath"> - <gl-truncate :text="item.job.label" /> - </gl-link> - <gl-badge v-else variant="info">{{ __('API') }}</gl-badge> - </template> - <template #cell(created)="{ item }"> - <time-ago-tooltip :time="item.created" /> - </template> - <template #cell(deployed)="{ item }"> - <time-ago-tooltip :time="item.deployed" /> - </template> - </gl-table-lite> + <div class="gl-relative gl-min-h-6"> + <div + v-if="isLoading" + class="gl-absolute gl-top-0 gl-left-0 gl-w-full gl-h-full gl-z-index-200 gl-bg-gray-10 gl-opacity-3" + ></div> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-absolute gl-top-half gl-left-50p" /> + <div v-if="isDeploymentTableShown"> + <deployments-table :deployments="deployments" /> + <pagination :page-info="pageInfo" :disabled="isPaginationDisabled" /> + </div> + <empty-state v-if="!isDeploymentTableShown && !isLoading" /> </div> </template> diff --git a/app/assets/javascripts/environments/environment_details/pagination.vue b/app/assets/javascripts/environments/environment_details/pagination.vue new file mode 100644 index 00000000000..414610b306a --- /dev/null +++ b/app/assets/javascripts/environments/environment_details/pagination.vue @@ -0,0 +1,74 @@ +<script> +import { GlKeysetPagination } from '@gitlab/ui'; +import { setUrlParams } from '~/lib/utils/url_utility'; +import { translations } from './constants'; + +export default { + components: { + GlKeysetPagination, + }, + props: { + pageInfo: { + type: Object, + required: true, + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + }, + translations, + computed: { + previousLink() { + if (!this.pageInfo || !this.pageInfo.hasPreviousPage) { + return ''; + } + return setUrlParams({ before: this.pageInfo.startCursor }, window.location.href, true); + }, + nextLink() { + if (!this.pageInfo || !this.pageInfo.hasNextPage) { + return ''; + } + return setUrlParams({ after: this.pageInfo.endCursor }, window.location.href, true); + }, + isPaginationVisible() { + if (!this.pageInfo) { + return false; + } + + return this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage; + }, + }, + methods: { + onPrev(previousCursor) { + this.$router.push({ query: { before: previousCursor } }); + }, + onNext(nextCursor) { + this.$router.push({ query: { after: nextCursor } }); + }, + onPaginationClick(event) { + // this check here is to ensure the proper default behvaior when a user ctrl/cmd + clicks the link + if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) { + return; + } + event.preventDefault(); + }, + }, +}; +</script> +<template> + <div v-if="isPaginationVisible" class="gl--flex-center"> + <gl-keyset-pagination + v-bind="pageInfo" + :prev-text="$options.translations.previousPageButtonLabel" + :next-text="$options.translations.nextPageButtonLabel" + :prev-button-link="previousLink" + :next-button-link="nextLink" + :disabled="disabled" + @prev="onPrev" + @next="onNext" + @click="onPaginationClick" + /> + </div> +</template> diff --git a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql index e8f2a2cdf7f..c6c2024c840 100644 --- a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql @@ -1,4 +1,11 @@ -query getEnvironmentDetails($projectFullPath: ID!, $environmentName: String, $pageSize: Int) { +query getEnvironmentDetails( + $projectFullPath: ID! + $environmentName: String + $first: Int + $last: Int + $after: String + $before: String +) { project(fullPath: $projectFullPath) { id name @@ -6,7 +13,19 @@ query getEnvironmentDetails($projectFullPath: ID!, $environmentName: String, $pa environment(name: $environmentName) { id name - deployments(orderBy: { createdAt: DESC }, first: $pageSize) { + deployments( + orderBy: { createdAt: DESC } + first: $first + last: $last + after: $after + before: $before + ) { + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } nodes { id iid diff --git a/app/assets/javascripts/environments/mount_show.js b/app/assets/javascripts/environments/mount_show.js index ba816599ac2..afce2b7f237 100644 --- a/app/assets/javascripts/environments/mount_show.js +++ b/app/assets/javascripts/environments/mount_show.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import EnvironmentsDetailHeader from './components/environments_detail_header.vue'; import { apolloProvider } from './graphql/client'; @@ -43,7 +44,7 @@ export const initHeader = () => { cancelAutoStopPath: dataset.environmentCancelAutoStopPath, terminalPath: dataset.environmentTerminalPath, metricsPath: dataset.environmentMetricsPath, - updatePath: dataset.tnvironmentEditPath, + updatePath: dataset.environmentEditPath, }, }); }, @@ -60,18 +61,40 @@ export const initPage = async () => { const dataSet = convertObjectPropsToCamelCase(JSON.parse(dataElement.dataset.details)); Vue.use(VueApollo); + Vue.use(VueRouter); const el = document.getElementById('environment_details_page'); + + const router = new VueRouter({ + mode: 'history', + base: window.location.pathname, + routes: [ + { + path: '/', + name: 'environment_details', + component: EnvironmentsDetailPage, + props: (route) => ({ + after: route.query.after, + before: route.query.before, + projectFullPath: dataSet.projectFullPath, + environmentName: dataSet.name, + }), + }, + ], + scrollBehavior(to, from, savedPosition) { + if (savedPosition) { + return savedPosition; + } + return { top: 0 }; + }, + }); + return new Vue({ el, apolloProvider: apolloProvider(), + router, provide: {}, render(createElement) { - return createElement(EnvironmentsDetailPage, { - props: { - projectFullPath: dataSet.projectFullPath, - environmentName: dataSet.name, - }, - }); + return createElement('router-view'); }, }); }; diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index 122c7c005e9..2a4bb88b6c2 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -161,7 +161,7 @@ export default { return this.pagination.next ? this.$options.NEXT_PAGE : null; }, errorTrackingHelpUrl() { - return helpPagePath('operations/error_tracking'); + return helpPagePath('operations/error_tracking.html#integrated-error-tracking'); }, showIntegratedDisabledAlert() { return !this.isAlertDismissed && this.showIntegratedTrackingDisabledAlert; @@ -175,6 +175,7 @@ export default { }, }, epicLink: 'https://gitlab.com/gitlab-org/gitlab/-/issues/353639', + openBetaLink: 'https://about.gitlab.com/handbook/product/gitlab-the-product/#open-beta', featureFlagLink: helpPagePath('operations/error_tracking'), created() { if (this.errorTrackingEnabled) { @@ -454,24 +455,19 @@ export default { /> </template> </div> - <div v-else-if="userCanEnableErrorTracking"> - <gl-empty-state - :title="__('Get started with error tracking')" - :description="__('Monitor your errors by integrating with Sentry.')" - :primary-button-text="__('Enable error tracking')" - :primary-button-link="enableErrorTrackingLink" - :svg-path="illustrationPath" - /> - </div> <div v-else> <gl-empty-state :title="__('Get started with error tracking')" :svg-path="illustrationPath"> <template #description> <div> - <span>{{ __('Monitor your errors by integrating with Sentry.') }}</span> + <span>{{ __('Monitor your errors directly in GitLab.') }}</span> <gl-link target="_blank" :href="errorTrackingHelpUrl">{{ - __('More information') + __('How do I get started?') }}</gl-link> </div> + <div class="gl-mt-3"> + <span>{{ __('Error tracking is currently in') }}</span> + <gl-link target="_blank" :href="$options.openBetaLink">{{ __('Open Beta.') }}</gl-link> + </div> </template> </gl-empty-state> </div> diff --git a/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue index 2323370a3aa..cd101f57d4f 100644 --- a/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue +++ b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue @@ -1,11 +1,10 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { getDisplayName } from '../utils'; export default { components: { - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, }, props: { dropdownLabel: { @@ -42,8 +41,21 @@ export default { required: true, }, }, + computed: { + listboxItems() { + return this.projects.map((project) => { + return { + text: getDisplayName(project), + value: project.id, + }; + }); + }, + }, methods: { - getDisplayName, + selectProject(id) { + const project = this.projects.find((p) => p.id === id); + this.$emit('select-project', project); + }, }, }; </script> @@ -52,22 +64,15 @@ export default { <div :class="{ 'gl-show-field-errors': isProjectInvalid }"> <label class="label-bold" for="project-dropdown">{{ __('Project') }}</label> <div class="row"> - <gl-dropdown + <gl-collapsible-listbox id="project-dropdown" - class="col-8 col-md-9 gl-pr-0" + class="gl-pl-5" :disabled="!hasProjects" - menu-class="w-100 mw-100" - toggle-class="dropdown-menu-toggle gl-field-error-outline" - :text="dropdownLabel" - > - <gl-dropdown-item - v-for="project in projects" - :key="`${project.organizationSlug}.${project.slug}`" - class="w-100" - @click="$emit('select-project', project)" - >{{ getDisplayName(project) }}</gl-dropdown-item - > - </gl-dropdown> + :items="listboxItems" + :selected="selectedProject && selectedProject.id" + :toggle-text="dropdownLabel" + @select="selectProject" + /> </div> <p v-if="isProjectInvalid" class="js-project-dropdown-error gl-field-error"> {{ invalidProjectLabel }} diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index 9e804b60d59..cebf73ef8e5 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -1,9 +1,7 @@ import * as Sentry from '@sentry/browser'; -import { escape } from 'lodash'; import Vue from 'vue'; import { GlAlert } from '@gitlab/ui'; import { __ } from '~/locale'; -import { spriteIcon } from './lib/utils/common_utils'; const FLASH_TYPES = { ALERT: 'alert', @@ -18,13 +16,6 @@ const VARIANT_DANGER = 'danger'; const VARIANT_INFO = 'info'; const VARIANT_TIP = 'tip'; -const TYPE_TO_VARIANT = { - [FLASH_TYPES.ALERT]: VARIANT_DANGER, - [FLASH_TYPES.NOTICE]: VARIANT_INFO, - [FLASH_TYPES.SUCCESS]: VARIANT_SUCCESS, - [FLASH_TYPES.WARNING]: VARIANT_WARNING, -}; - const FLASH_CLOSED_EVENT = 'flashClosed'; const getCloseEl = (flashEl) => { @@ -57,27 +48,6 @@ const hideFlash = (flashEl, fadeTransition = true) => { if (!fadeTransition) flashEl.dispatchEvent(new Event('transitionend')); }; -const createAction = (config) => ` - <a - href="${config.href || '#'}" - class="flash-action" - ${config.href ? '' : 'role="button"'} - > - ${escape(config.title)} - </a> -`; - -const createFlashEl = (message, type) => ` - <div class="flash-${type}" data-testid="alert-${TYPE_TO_VARIANT[type]}"> - <div class="flash-text"> - ${escape(message)} - <div class="close-icon-wrapper js-close-icon"> - ${spriteIcon('close', 'close-icon')} - </div> - </div> - </div> -`; - const addDismissFlashClickListener = (flashEl, fadeTransition) => { // There are some flash elements which do not have a closeEl. // https://gitlab.com/gitlab-org/gitlab/blob/763426ef344488972eb63ea5be8744e0f8459e6b/ee/app/views/layouts/header/_read_only_banner.html.haml @@ -211,73 +181,7 @@ const createAlert = function createAlert({ }); }; -/** - * @deprecated use `createAlert` instead - * - * Flash banner supports different types of Flash configurations - * along with ability to provide actionConfig which can be used to show - * additional action or link on banner next to message - * - * @param {object} options - Options to control the flash message - * @param {string} options.message - Flash message text - * @param {'alert'|'notice'|'success'|'warning'} [options.type] - Type of Flash; it defaults to 'alert' - * @param {Element|Document} [options.parent] - Reference to parent element under which Flash needs to appear - * @param {object} [options.actionConfig] - Map of config to show action on banner - * @param {string} [options.actionConfig.href] - URL to which action config should point to (default: '#') - * @param {string} [options.actionConfig.title] - Title of action - * @param {Function} [options.actionConfig.clickHandler] - Method to call when action is clicked on - * @param {boolean} [options.fadeTransition] - Boolean to determine whether to fade the alert out - * @param {boolean} [options.addBodyClass] - Adds `flash-shown` class to the `body` element - * @param {boolean} [options.captureError] - Boolean to determine whether to send error to Sentry - * @param {object} [options.error] - Error to be captured in Sentry - */ -const createFlash = function createFlash({ - message, - type = FLASH_TYPES.ALERT, - parent = document, - actionConfig = null, - fadeTransition = true, - addBodyClass = false, - captureError = false, - error = null, -}) { - const flashContainer = parent.querySelector('.flash-container'); - - if (!flashContainer) return null; - - // eslint-disable-next-line no-unsanitized/property - flashContainer.innerHTML = createFlashEl(message, type); - - const flashEl = flashContainer.querySelector(`.flash-${type}`); - - if (actionConfig) { - // eslint-disable-next-line no-unsanitized/method - flashEl.insertAdjacentHTML('beforeend', createAction(actionConfig)); - - if (actionConfig.clickHandler) { - flashEl - .querySelector('.flash-action') - .addEventListener('click', (e) => actionConfig.clickHandler(e)); - } - } - - addDismissFlashClickListener(flashEl, fadeTransition); - - flashContainer.classList.add('gl-display-block'); - - if (addBodyClass) document.body.classList.add('flash-shown'); - - if (captureError && error) Sentry.captureException(error); - - flashContainer.close = () => { - getCloseEl(flashEl).click(); - }; - - return flashContainer; -}; - export { - createFlash as default, hideFlash, addDismissFlashClickListener, FLASH_TYPES, diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue index 8ad9eeaa266..a4e883c96b5 100644 --- a/app/assets/javascripts/frequent_items/components/app.vue +++ b/app/assets/javascripts/frequent_items/components/app.vue @@ -1,5 +1,5 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import AccessorUtilities from '~/lib/utils/accessor'; import { mapVuexModuleState, @@ -18,6 +18,11 @@ export default { FrequentItemsSearchInput, FrequentItemsList, GlLoadingIcon, + GlButton, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, }, mixins: [frequentItemsMixin], inject: ['vuexModule'], @@ -40,12 +45,14 @@ export default { ...mapVuexModuleState((vm) => vm.vuexModule, [ 'searchQuery', 'isLoadingItems', + 'isItemsListEditable', 'isFetchFailed', + 'isItemRemovalFailed', 'items', ]), ...mapVuexModuleGetters((vm) => vm.vuexModule, ['hasSearchQuery']), translations() { - return this.getTranslations(['loadingMessage', 'header']); + return this.getTranslations(['loadingMessage', 'header', 'headerEditToggle']); }, }, created() { @@ -74,6 +81,7 @@ export default { ...mapVuexModuleActions((vm) => vm.vuexModule, [ 'setNamespace', 'setStorageKey', + 'toggleItemsListEditablity', 'fetchFrequentItems', ]), dropdownOpenHandler() { @@ -132,8 +140,25 @@ export default { class="loading-animation prepend-top-20" data-testid="loading" /> - <div v-if="!isLoadingItems && !hasSearchQuery" class="section-header" data-testid="header"> - {{ translations.header }} + <div + v-if="!isLoadingItems && !hasSearchQuery" + class="section-header gl-display-flex" + data-testid="header" + > + <span class="gl-flex-grow-1">{{ translations.header }}</span> + <gl-button + v-if="items.length" + v-gl-tooltip.left + size="small" + category="tertiary" + :aria-label="translations.headerEditToggle" + :title="translations.headerEditToggle" + :class="{ 'gl-bg-gray-100!': isItemsListEditable }" + class="gl-p-2!" + @click="toggleItemsListEditablity" + > + <gl-icon name="pencil" :class="{ 'gl-text-gray-900!': isItemsListEditable }" /> + </gl-button> </div> <frequent-items-list v-if="!isLoadingItems" @@ -141,6 +166,7 @@ export default { :namespace="namespace" :has-search-query="hasSearchQuery" :is-fetch-failed="isFetchFailed" + :is-item-removal-failed="isItemRemovalFailed" :matcher="searchQuery" /> </div> diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue index c0bfcf9c4a9..da1d3bedaf4 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_list.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue @@ -21,6 +21,10 @@ export default { type: Boolean, required: true, }, + isItemRemovalFailed: { + type: Boolean, + required: true, + }, matcher: { type: String, required: true, @@ -38,6 +42,9 @@ export default { isListEmpty() { return this.items.length === 0; }, + showListEmptyMessage() { + return this.isListEmpty || this.isItemRemovalFailed; + }, listEmptyMessage() { if (this.hasSearchQuery) { return this.isFetchFailed @@ -45,7 +52,7 @@ export default { : this.translations.searchListEmptyMessage; } - return this.isFetchFailed + return this.isFetchFailed || this.isItemRemovalFailed ? this.translations.itemListErrorMessage : this.translations.itemListEmptyMessage; }, @@ -60,9 +67,10 @@ export default { <div class="frequent-items-list-container"> <ul data-testid="frequent-items-list" class="list-unstyled"> <li - v-if="isListEmpty" + v-if="showListEmptyMessage" :class="{ 'section-failure': isFetchFailed }" class="section-empty gl-mb-3" + data-testid="frequent-items-list-empty" > {{ listEmptyMessage }} </li> diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue index 89b6885091c..75ea9beb5cf 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue @@ -1,10 +1,10 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { snakeCase } from 'lodash'; import SafeHtml from '~/vue_shared/directives/safe_html'; import highlight from '~/lib/utils/highlight'; import { truncateNamespace } from '~/lib/utils/text_utility'; -import { mapVuexModuleState } from '~/lib/utils/vuex_module_mappers'; +import { mapVuexModuleState, mapVuexModuleActions } from '~/lib/utils/vuex_module_mappers'; import Tracking from '~/tracking'; import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; @@ -12,11 +12,13 @@ const trackingMixin = Tracking.mixin(); export default { components: { + GlIcon, GlButton, ProjectAvatar, }, directives: { SafeHtml, + GlTooltip: GlTooltipDirective, }, mixins: [trackingMixin], inject: ['vuexModule'], @@ -51,7 +53,7 @@ export default { }, }, computed: { - ...mapVuexModuleState((vm) => vm.vuexModule, ['dropdownType']), + ...mapVuexModuleState((vm) => vm.vuexModule, ['dropdownType', 'isItemsListEditable']), truncatedNamespace() { return truncateNamespace(this.namespace); }, @@ -62,43 +64,63 @@ export default { return `${this.dropdownType}_dropdown_frequent_items_list_item_${snakeCase(this.itemName)}`; }, }, + methods: { + ...mapVuexModuleActions((vm) => vm.vuexModule, ['removeFrequentItem']), + }, }; </script> <template> - <li class="frequent-items-list-item-container"> + <li class="frequent-items-list-item-container gl-relative"> <gl-button category="tertiary" :href="webUrl" - class="gl-text-left gl-justify-content-start!" + class="gl-text-left gl-w-full" + button-text-classes="gl-display-flex gl-w-full" + data-testid="frequent-item-link" @click="track('click_link', { label: itemTrackingLabel })" > - <project-avatar - class="gl-float-left gl-mr-3" - :project-avatar-url="avatarUrl" - :project-id="itemId" - :project-name="itemName" - aria-hidden="true" - /> - <div - data-testid="frequent-items-item-metadata-container" - class="frequent-items-item-metadata-container" - > - <div - v-safe-html="highlightedItemName" - data-testid="frequent-items-item-title" - :title="itemName" - class="frequent-items-item-title" - ></div> + <div class="gl-flex-grow-1"> + <project-avatar + class="gl-float-left gl-mr-3" + :project-avatar-url="avatarUrl" + :project-id="itemId" + :project-name="itemName" + aria-hidden="true" + /> <div - v-if="namespace" - data-testid="frequent-items-item-namespace" - :title="namespace" - class="frequent-items-item-namespace" + data-testid="frequent-items-item-metadata-container" + class="frequent-items-item-metadata-container" > - {{ truncatedNamespace }} + <div + v-safe-html="highlightedItemName" + data-testid="frequent-items-item-title" + :title="itemName" + class="frequent-items-item-title" + ></div> + <div + v-if="namespace" + data-testid="frequent-items-item-namespace" + :title="namespace" + class="frequent-items-item-namespace" + > + {{ truncatedNamespace }} + </div> </div> </div> </gl-button> + <gl-button + v-if="isItemsListEditable" + v-gl-tooltip.left + size="small" + category="tertiary" + :aria-label="__('Remove')" + :title="__('Remove')" + class="gl-align-self-center gl-p-1! gl-absolute! gl-w-auto! gl-top-4 gl-right-4" + data-testid="item-remove" + @click.stop.prevent="removeFrequentItem(itemId)" + > + <gl-icon name="close" /> + </gl-button> </li> </template> diff --git a/app/assets/javascripts/frequent_items/constants.js b/app/assets/javascripts/frequent_items/constants.js index cb5d21161a9..a7c27abf58e 100644 --- a/app/assets/javascripts/frequent_items/constants.js +++ b/app/assets/javascripts/frequent_items/constants.js @@ -18,6 +18,7 @@ export const TRANSLATION_KEYS = { projects: { loadingMessage: s__('ProjectsDropdown|Loading projects'), header: s__('ProjectsDropdown|Frequently visited'), + headerEditToggle: s__('ProjectsDropdown|Toggle edit mode'), itemListErrorMessage: s__( 'ProjectsDropdown|This feature requires browser localStorage support', ), @@ -29,6 +30,7 @@ export const TRANSLATION_KEYS = { groups: { loadingMessage: s__('GroupsDropdown|Loading groups'), header: s__('GroupsDropdown|Frequently visited'), + headerEditToggle: s__('GroupsDropdown|Toggle edit mode'), itemListErrorMessage: s__('GroupsDropdown|This feature requires browser localStorage support'), itemListEmptyMessage: s__('GroupsDropdown|Groups you visit often will appear here'), searchListErrorMessage: s__('GroupsDropdown|Something went wrong on our end.'), diff --git a/app/assets/javascripts/frequent_items/store/actions.js b/app/assets/javascripts/frequent_items/store/actions.js index babc2ef2e32..e5ef49ec402 100644 --- a/app/assets/javascripts/frequent_items/store/actions.js +++ b/app/assets/javascripts/frequent_items/store/actions.js @@ -12,6 +12,10 @@ export const setStorageKey = ({ commit }, key) => { commit(types.SET_STORAGE_KEY, key); }; +export const toggleItemsListEditablity = ({ commit }) => { + commit(types.TOGGLE_ITEMS_LIST_EDITABILITY); +}; + export const requestFrequentItems = ({ commit }) => { commit(types.REQUEST_FREQUENT_ITEMS); }; @@ -81,3 +85,28 @@ export const setSearchQuery = ({ commit, dispatch }, query) => { dispatch('fetchFrequentItems'); } }; + +export const removeFrequentItemSuccess = ({ commit }, itemId) => { + commit(types.RECEIVE_REMOVE_FREQUENT_ITEM_SUCCESS, itemId); +}; + +export const removeFrequentItemError = ({ commit }) => { + commit(types.RECEIVE_REMOVE_FREQUENT_ITEM_ERROR); +}; + +export const removeFrequentItem = ({ state, dispatch }, itemId) => { + if (AccessorUtilities.canUseLocalStorage()) { + try { + const storedRawItems = JSON.parse(localStorage.getItem(state.storageKey)); + localStorage.setItem( + state.storageKey, + JSON.stringify(storedRawItems.filter((item) => item.id !== itemId)), + ); + dispatch('removeFrequentItemSuccess', itemId); + } catch { + dispatch('removeFrequentItemError'); + } + } else { + dispatch('removeFrequentItemError'); + } +}; diff --git a/app/assets/javascripts/frequent_items/store/mutation_types.js b/app/assets/javascripts/frequent_items/store/mutation_types.js index cbe2c9401ad..9c9346081e9 100644 --- a/app/assets/javascripts/frequent_items/store/mutation_types.js +++ b/app/assets/javascripts/frequent_items/store/mutation_types.js @@ -1,9 +1,12 @@ export const SET_NAMESPACE = 'SET_NAMESPACE'; export const SET_STORAGE_KEY = 'SET_STORAGE_KEY'; export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY'; +export const TOGGLE_ITEMS_LIST_EDITABILITY = 'TOGGLE_ITEMS_LIST_EDITABILITY'; export const REQUEST_FREQUENT_ITEMS = 'REQUEST_FREQUENT_ITEMS'; export const RECEIVE_FREQUENT_ITEMS_SUCCESS = 'RECEIVE_FREQUENT_ITEMS_SUCCESS'; export const RECEIVE_FREQUENT_ITEMS_ERROR = 'RECEIVE_FREQUENT_ITEMS_ERROR'; export const REQUEST_SEARCHED_ITEMS = 'REQUEST_SEARCHED_ITEMS'; export const RECEIVE_SEARCHED_ITEMS_SUCCESS = 'RECEIVE_SEARCHED_ITEMS_SUCCESS'; export const RECEIVE_SEARCHED_ITEMS_ERROR = 'RECEIVE_SEARCHED_ITEMS_ERROR'; +export const RECEIVE_REMOVE_FREQUENT_ITEM_SUCCESS = 'RECEIVE_REMOVE_FREQUENT_ITEM_SUCCESS'; +export const RECEIVE_REMOVE_FREQUENT_ITEM_ERROR = 'RECEIVE_REMOVE_FREQUENT_ITEM_ERROR'; diff --git a/app/assets/javascripts/frequent_items/store/mutations.js b/app/assets/javascripts/frequent_items/store/mutations.js index eee00243867..65f54e6ed05 100644 --- a/app/assets/javascripts/frequent_items/store/mutations.js +++ b/app/assets/javascripts/frequent_items/store/mutations.js @@ -20,6 +20,11 @@ export default { hasSearchQuery, }); }, + [types.TOGGLE_ITEMS_LIST_EDITABILITY](state) { + Object.assign(state, { + isItemsListEditable: !state.isItemsListEditable, + }); + }, [types.REQUEST_FREQUENT_ITEMS](state) { Object.assign(state, { isLoadingItems: true, @@ -69,4 +74,15 @@ export default { isFetchFailed: true, }); }, + [types.RECEIVE_REMOVE_FREQUENT_ITEM_SUCCESS](state, itemId) { + Object.assign(state, { + items: state.items.filter((item) => item.id !== itemId), + isItemRemovalFailed: false, + }); + }, + [types.RECEIVE_REMOVE_FREQUENT_ITEM_ERROR](state) { + Object.assign(state, { + isItemRemovalFailed: true, + }); + }, }; diff --git a/app/assets/javascripts/frequent_items/store/state.js b/app/assets/javascripts/frequent_items/store/state.js index c5c0b25fdf2..ee94e9cd221 100644 --- a/app/assets/javascripts/frequent_items/store/state.js +++ b/app/assets/javascripts/frequent_items/store/state.js @@ -5,5 +5,7 @@ export default ({ dropdownType = '' } = {}) => ({ searchQuery: '', isLoadingItems: false, isFetchFailed: false, + isItemsListEditable: false, + isItemRemovalFailed: false, items: [], }); diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 293cd2df16f..81da8409873 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -39,8 +39,18 @@ export const CONTACTS_REMOVE_COMMAND = '/remove_contacts'; * @param string user input * @return {string} escaped user input */ -function escape(string) { - return lodashEscape(string).replace(/\$/g, '$'); +export function escape(string) { + // To prevent double (or multiple) enconding attack + // Decode the user input repeatedly prior to escaping the final decoded string. + let encodedString = string; + let decodedString = decodeURIComponent(encodedString); + + while (decodedString !== encodedString) { + encodedString = decodeURIComponent(decodedString); + decodedString = decodeURIComponent(encodedString); + } + + return lodashEscape(decodedString.replace(/\$/g, '$')); } export function showAndHideHelper($input, alias = '') { @@ -106,6 +116,7 @@ export const defaultAutocompleteConfig = { issues: true, mergeRequests: true, epics: true, + iterations: true, milestones: true, labels: true, snippets: true, @@ -209,6 +220,10 @@ class GfmAutoComplete { [[referencePrefix]] = value.params; if (/^[@%~]/.test(referencePrefix)) { tpl += '<%- referencePrefix %>'; + } else if (/^[*]/.test(referencePrefix)) { + // EE-ONLY + referencePrefix = '*iteration:'; + tpl += '<%- referencePrefix %>'; } } } @@ -883,7 +898,8 @@ class GfmAutoComplete { const atSymbolsWithBar = Object.keys(controllers) .join('|') .replace(/[$]/, '\\$&') - .replace(/([[\]:])/g, '\\$1'); + .replace(/([[\]:])/g, '\\$1') + .replace(/([*])/g, '\\$1'); const atSymbolsWithoutBar = Object.keys(controllers).join(''); const targetSubtext = subtext.split(GfmAutoComplete.regexSubtext).pop(); @@ -912,6 +928,7 @@ GfmAutoComplete.atTypeMap = { '#': 'issues', '!': 'mergeRequests', '&': 'epics', + '*iteration:': 'iterations', '~': 'labels', '%': 'milestones', '/': 'commands', diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js index 15e7ef7d62c..01cc2fc3018 100644 --- a/app/assets/javascripts/graphql_shared/issuable_client.js +++ b/app/assets/javascripts/graphql_shared/issuable_client.js @@ -5,6 +5,7 @@ import { concatPagination } from '@apollo/client/utilities'; import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql'; import createDefaultClient from '~/lib/graphql'; import typeDefs from '~/work_items/graphql/typedefs.graphql'; +import { WIDGET_TYPE_NOTES } from '~/work_items/constants'; export const config = { typeDefs, @@ -22,10 +23,30 @@ export const config = { }, }, }, + WorkItemWidgetNotes: { + fields: { + // If we add any key args, the discussions field becomes discussions({"filter":"ONLY_ACTIVITY","first":10}) and + // kills any possibility to handle it on the widget level without hardcoding a string. + discussions: { + keyArgs: false, + }, + }, + }, + WorkItemWidgetProgress: { + fields: { + progress: { + // We want to show null progress as 0 as per https://gitlab.com/gitlab-org/gitlab/-/issues/386117 + read(existing) { + return existing === null ? 0 : existing; + }, + }, + }, + }, WorkItem: { fields: { + // widgets policy because otherwise the subscriptions invalidate the cache widgets: { - merge(existing = [], incoming) { + merge(existing = [], incoming, context) { if (existing.length === 0) { return incoming; } @@ -33,6 +54,24 @@ export const config = { const incomingWidget = incoming.find( (w) => w.type && w.type === existingWidget.type, ); + // We don't want to override existing notes with empty widget on work item updates + if (incomingWidget?.type === WIDGET_TYPE_NOTES && !context.variables.pageSize) { + return existingWidget; + } + // we want to concat next page of discussions to the existing ones + if (incomingWidget?.type === WIDGET_TYPE_NOTES && context.variables.after) { + // concatPagination won't work because we were placing new widget here so we have to do this manually + return { + ...incomingWidget, + discussions: { + ...incomingWidget.discussions, + nodes: [ + ...existingWidget.discussions.nodes, + ...incomingWidget.discussions.nodes, + ], + }, + }; + } return incomingWidget || existingWidget; }); }, diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json index 5467105ac3c..a622b342c0a 100644 --- a/app/assets/javascripts/graphql_shared/possible_types.json +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -153,8 +153,9 @@ "WorkItemWidgetMilestone", "WorkItemWidgetNotes", "WorkItemWidgetProgress", + "WorkItemWidgetRequirementLegacy", "WorkItemWidgetStartAndDueDate", "WorkItemWidgetStatus", "WorkItemWidgetWeight" ] -}
\ No newline at end of file +} diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js deleted file mode 100644 index fb0c47fe018..00000000000 --- a/app/assets/javascripts/groups_select.js +++ /dev/null @@ -1,122 +0,0 @@ -import Vue from 'vue'; -import $ from 'jquery'; -import { escape } from 'lodash'; -import GroupSelect from '~/vue_shared/components/group_select/group_select.vue'; -import { groupsPath } from '~/vue_shared/components/group_select/utils'; -import { __ } from '~/locale'; -import Api from './api'; -import { loadCSSFile } from './lib/utils/css_utils'; -import { select2AxiosTransport } from './lib/utils/select2_utils'; - -const initVueSelect = () => { - [...document.querySelectorAll('.ajax-groups-select')].forEach((el) => { - const { parentId: parentGroupID, groupsFilter, inputId } = el.dataset; - - return new Vue({ - el, - components: { - GroupSelect, - }, - render(createElement) { - return createElement(GroupSelect, { - props: { - inputName: el.name, - initialSelection: el.value || null, - parentGroupID, - groupsFilter, - inputId, - clearable: el.classList.contains('allowClear'), - }, - }); - }, - }); - }); -}; - -const groupsSelect = () => { - loadCSSFile(gon.select2_css_path) - .then(() => { - // Needs to be accessible in rspec - window.GROUP_SELECT_PER_PAGE = 20; - - $('.ajax-groups-select').each(function setAjaxGroupsSelect2() { - const $select = $(this); - const allAvailable = $select.data('allAvailable'); - const skipGroups = $select.data('skipGroups') || []; - const parentGroupID = $select.data('parentId'); - const groupsFilter = $select.data('groupsFilter'); - const minAccessLevel = $select.data('minAccessLevel'); - - $select.select2({ - placeholder: __('Search for a group'), - allowClear: $select.hasClass('allowClear'), - multiple: $select.hasClass('multiselect'), - minimumInputLength: 0, - ajax: { - url: Api.buildUrl(groupsPath(groupsFilter, parentGroupID)), - dataType: 'json', - quietMillis: 250, - transport: select2AxiosTransport, - data(search, page) { - return { - search, - page, - per_page: window.GROUP_SELECT_PER_PAGE, - all_available: allAvailable, - min_access_level: minAccessLevel, - }; - }, - results(data, page) { - const groups = data.length ? data : data.results || []; - const more = data.pagination ? data.pagination.more : false; - const results = groups.filter((group) => skipGroups.indexOf(group.id) === -1); - - return { - results, - page, - more, - }; - }, - }, - // eslint-disable-next-line consistent-return - initSelection(element, callback) { - const id = $(element).val(); - if (id !== '') { - return Api.group(id, callback); - } - }, - formatResult(object) { - return `<div class='group-result'> <div class='group-name'>${escape( - object.full_name, - )}</div> <div class='group-path'>${object.full_path}</div> </div>`; - }, - formatSelection(object) { - return escape(object.full_name); - }, - dropdownCssClass: 'ajax-groups-dropdown select2-infinite', - // we do not want to escape markup since we are displaying html in results - escapeMarkup(m) { - return m; - }, - }); - - $select.on('select2-loaded', () => { - const dropdown = document.querySelector('.select2-infinite .select2-results'); - dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`; - }); - }); - }) - .catch(() => {}); -}; - -export default () => { - if ($('.ajax-groups-select').length) { - if (gon.features?.vueGroupSelect) { - initVueSelect(); - } else { - import(/* webpackChunkName: 'select2' */ 'select2/select2') - .then(groupsSelect) - .catch(() => {}); - } - } -}; diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 3c9c0b1ade1..b95f8bb5acb 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -27,7 +27,6 @@ import { performanceMarkAndMeasure } from '~/performance/utils'; import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { leftSidebarViews, viewerTypes, @@ -55,7 +54,6 @@ export default { DiffViewer, FileTemplatesBar, }, - mixins: [glFeatureFlagMixin()], props: { file: { type: Object, @@ -474,7 +472,7 @@ export default { this.editor.registerCiSchema(); }; - if (this.isCiConfigFile && this.glFeatures.schemaLinting) { + if (this.isCiConfigFile) { registerLocalSchema(); } else { if (this.CiSchemaExtension) { diff --git a/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js index fbd2ce4ce69..dbb68b7facd 100644 --- a/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js +++ b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js @@ -1,7 +1,12 @@ -import { cleanEndingSeparator } from '~/lib/utils/url_utility'; +import { cleanEndingSeparator, joinPaths } from '~/lib/utils/url_utility'; const getBaseUrl = () => { - const baseUrlObj = new URL(process.env.GITLAB_WEB_IDE_PUBLIC_PATH, window.location.origin); + const path = joinPaths( + '/', + window.gon.relative_url_root || '', + process.env.GITLAB_WEB_IDE_PUBLIC_PATH, + ); + const baseUrlObj = new URL(path, window.location.origin); return cleanEndingSeparator(baseUrlObj.href); }; diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index cbc6e0fe519..d490b8c5dad 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -183,7 +183,11 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo dispatch( 'redirectToUrl', - createNewMergeRequestUrl(currentProject.web_url, branchName, targetBranch), + createNewMergeRequestUrl( + currentProject.web_url, + encodeURIComponent(branchName), + encodeURIComponent(targetBranch), + ), { root: true }, ); } diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js index 62476b7fc63..6eb56a68429 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js @@ -1,6 +1,6 @@ import axios from 'axios'; import Visibility from 'visibilityjs'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status'; import Poll from '~/lib/utils/poll'; import { __ } from '~/locale'; import { rightSidebarViews } from '../../../constants'; @@ -24,7 +24,7 @@ export const forcePipelineRequest = () => { export const requestLatestPipeline = ({ commit }) => commit(types.REQUEST_LATEST_PIPELINE); export const receiveLatestPipelineError = ({ commit, dispatch }, err) => { - if (err.status !== httpStatus.NOT_FOUND) { + if (err.status !== HTTP_STATUS_NOT_FOUND) { dispatch( 'setErrorMessage', { diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js index 91645a34a3d..c4198a7427f 100644 --- a/app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js @@ -1,5 +1,5 @@ import Api from '~/api'; -import httpStatus from '~/lib/utils/http_status'; +import { HTTP_STATUS_FORBIDDEN, HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status'; import * as terminalService from '../../../../services/terminals'; import { CHECK_CONFIG, CHECK_RUNNERS, RETRY_RUNNERS_INTERVAL } from '../constants'; import * as messages from '../messages'; @@ -18,7 +18,7 @@ export const receiveConfigCheckError = ({ commit, state }, e) => { const { status } = e.response; const { paths } = state; - const isVisible = status !== httpStatus.FORBIDDEN && status !== httpStatus.NOT_FOUND; + const isVisible = status !== HTTP_STATUS_FORBIDDEN && status !== HTTP_STATUS_NOT_FOUND; commit(types.SET_VISIBLE, isVisible); const message = messages.configCheckError(status, paths.webTerminalConfigHelpPath); diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js index a510ec0847b..874cc5094d3 100644 --- a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js @@ -1,6 +1,6 @@ import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import httpStatus, { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; +import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; import * as terminalService from '../../../../services/terminals'; import { STARTING, STOPPING, STOPPED } from '../constants'; import * as messages from '../messages'; @@ -107,7 +107,7 @@ export const restartSession = ({ state, dispatch, rootState }) => { const responseStatus = error.response && error.response.status; // We may have removed the build, in this case we'll just create a new session if ( - responseStatus === httpStatus.NOT_FOUND || + responseStatus === HTTP_STATUS_NOT_FOUND || responseStatus === HTTP_STATUS_UNPROCESSABLE_ENTITY ) { dispatch('startSession'); diff --git a/app/assets/javascripts/ide/stores/modules/terminal/messages.js b/app/assets/javascripts/ide/stores/modules/terminal/messages.js index fa1c7f23677..ad7ad35a98c 100644 --- a/app/assets/javascripts/ide/stores/modules/terminal/messages.js +++ b/app/assets/javascripts/ide/stores/modules/terminal/messages.js @@ -1,5 +1,5 @@ import { escape } from 'lodash'; -import httpStatus, { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; +import { HTTP_STATUS_FORBIDDEN, HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; import { __, sprintf } from '~/locale'; export const UNEXPECTED_ERROR_CONFIG = __( @@ -39,7 +39,7 @@ export const configCheckError = (status, helpUrl) => { }, false, ); - } else if (status === httpStatus.FORBIDDEN) { + } else if (status === HTTP_STATUS_FORBIDDEN) { return ERROR_PERMISSION; } diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue index bd69165f0ca..db677c574d1 100644 --- a/app/assets/javascripts/import_entities/components/import_status.vue +++ b/app/assets/javascripts/import_entities/components/import_status.vue @@ -7,13 +7,21 @@ import { STATUSES } from '../constants'; const STATISTIC_ITEMS = { diff_note: __('Diff notes'), issue: __('Issues'), + issue_attachment: s__('GithubImporter|Issue attachments'), + issue_event: __('Issue events'), label: __('Labels'), + lfs_object: __('LFS objects'), + merge_request_attachment: s__('GithubImporter|Merge request attachments'), milestone: __('Milestones'), note: __('Notes'), + note_attachment: s__('GithubImporter|Note attachments'), + protected_branch: __('Protected branches'), pull_request: s__('GithubImporter|Pull requests'), pull_request_merged_by: s__('GithubImporter|PR mergers'), pull_request_review: s__('GithubImporter|PR reviews'), + pull_request_review_request: s__('GithubImporter|PR reviews'), release: __('Releases'), + release_attachment: s__('GithubImporter|Release attachments'), }; // support both camel case and snake case versions @@ -93,18 +101,17 @@ export default { mappedStatus() { if (this.status === STATUSES.FINISHED) { const isIncomplete = this.stats && isIncompleteImport(this.stats); - return { - icon: 'status-success', - ...(isIncomplete - ? { - text: __('Partial import'), - variant: 'warning', - } - : { - text: __('Complete'), - variant: 'success', - }), - }; + return isIncomplete + ? { + icon: 'status-alert', + text: __('Partial import'), + variant: 'warning', + } + : { + icon: 'status-success', + text: __('Complete'), + variant: 'success', + }; } return STATUS_MAP[this.status]; @@ -120,6 +127,8 @@ export default { return { name: 'status-success', class: 'gl-text-green-400' }; } else if (imported === 0) { return { name: 'status-scheduled', class: 'gl-text-gray-400' }; + } else if (this.status === STATUSES.FINISHED) { + return { name: 'status-alert', class: 'gl-text-orange-400' }; } return { name: 'status-running', class: 'gl-text-blue-400' }; diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue index deaf2654424..8d72942447c 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue @@ -1,15 +1,27 @@ <script> -import { GlButton, GlIcon, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import { + GlButton, + GlDropdown, + GlDropdownItem, + GlIcon, + GlTooltipDirective as GlTooltip, +} from '@gitlab/ui'; export default { components: { GlIcon, GlButton, + GlDropdown, + GlDropdownItem, }, directives: { GlTooltip, }, props: { + isProjectsImportEnabled: { + type: Boolean, + required: true, + }, isFinished: { type: Boolean, required: true, @@ -23,13 +35,32 @@ export default { required: true, }, }, + methods: { + importGroup(extraArgs = {}) { + this.$emit('import-group', extraArgs); + }, + }, }; </script> <template> <span class="gl-white-space-nowrap gl-inline-flex gl-align-items-center"> + <gl-dropdown + v-if="isProjectsImportEnabled && isAvailableForImport" + :text="isFinished ? __('Re-import with projects') : __('Import with projects')" + :disabled="isInvalid" + variant="confirm" + category="secondary" + data-qa-selector="import_group_button" + split + @click="importGroup({ migrateProjects: true })" + > + <gl-dropdown-item @click="importGroup({ migrateProjects: false })">{{ + isFinished ? __('Re-import without projects') : __('Import without projects') + }}</gl-dropdown-item> + </gl-dropdown> <gl-button - v-if="isAvailableForImport" + v-else-if="isAvailableForImport" :disabled="isInvalid" variant="confirm" category="secondary" 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 6412f26fde7..c590d832568 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 @@ -2,6 +2,8 @@ import { GlAlert, GlButton, + GlDropdown, + GlDropdownItem, GlEmptyState, GlIcon, GlLink, @@ -15,6 +17,7 @@ import { import { debounce } from 'lodash'; import { createAlert } from '~/flash'; import { s__, __, n__, sprintf } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; import { getGroupPathAvailability } from '~/rest_api'; @@ -47,6 +50,8 @@ export default { components: { GlAlert, GlButton, + GlDropdown, + GlDropdownItem, GlEmptyState, GlIcon, GlLink, @@ -65,6 +70,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagsMixin()], props: { sourceUrl: { type: String, @@ -128,32 +134,36 @@ export default { { key: 'webUrl', label: s__('BulkImport|Source group'), - thClass: `${DEFAULT_TH_CLASSES} gl-pl-0! import-jobs-from-col`, + thClass: `${DEFAULT_TH_CLASSES} gl-pl-0! gl-w-half`, // eslint-disable-next-line @gitlab/require-i18n-strings tdClass: `${DEFAULT_TD_CLASSES} gl-pl-0!`, }, { key: 'importTarget', label: s__('BulkImport|New group'), - thClass: `${DEFAULT_TH_CLASSES} import-jobs-to-col`, + thClass: `${DEFAULT_TH_CLASSES} gl-w-half`, tdClass: DEFAULT_TD_CLASSES, }, { key: 'progress', label: __('Status'), - thClass: `${DEFAULT_TH_CLASSES} import-jobs-status-col`, + thClass: `${DEFAULT_TH_CLASSES}`, tdClass: DEFAULT_TD_CLASSES, tdAttr: { 'data-qa-selector': 'import_status_indicator' }, }, { key: 'actions', label: '', - thClass: `${DEFAULT_TH_CLASSES} import-jobs-cta-col`, + thClass: `${DEFAULT_TH_CLASSES}`, tdClass: DEFAULT_TD_CLASSES, }, ], computed: { + isProjectsImportEnabled() { + return Boolean(this.glFeatures.bulkImportProjects); + }, + groups() { return this.bulkImportSourceGroups?.nodes ?? []; }, @@ -260,7 +270,7 @@ export default { const table = this.getTableRef(); const matches = new Set(); this.groups.forEach((g, idx) => { - if (!this.importGroups[g.id]) { + if (!this.importTargets[g.id]) { this.setDefaultImportTarget(g); } @@ -375,13 +385,14 @@ export default { } }, - importSelectedGroups() { + importSelectedGroups(extraArgs = {}) { const importRequests = this.groupsTableData .filter((group) => this.selectedGroupsIds.includes(group.id)) .map((group) => ({ sourceGroupId: group.id, targetNamespace: group.importTarget.targetNamespace.fullPath, newName: group.importTarget.newName, + ...extraArgs, })); this.importGroups(importRequests); @@ -521,6 +532,7 @@ export default { gitlabLogo: window.gon.gitlab_logo, PAGE_SIZES, permissionsHelpPath: helpPagePath('user/permissions', { anchor: 'group-members-permissions' }), + betaFeatureHelpPath: helpPagePath('policy/alpha-beta-support', { anchor: 'beta-features' }), popoverOptions: { title: __('What is listed here?') }, i18n, LOCAL_STORAGE_KEY: 'gl-bulk-imports-status-page-size-v1', @@ -637,7 +649,7 @@ export default { </gl-empty-state> <template v-else> <div - class="gl-bg-gray-10 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-px-4 gl-display-flex gl-align-items-center import-table-bar" + class="gl-bg-gray-10 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-px-4 gl-display-flex gl-align-items-center gl-sticky gl-z-index-3 import-table-bar" > <span data-test-id="selection-count"> <gl-sprintf :message="__('%{count} selected')"> @@ -646,7 +658,22 @@ export default { </template> </gl-sprintf> </span> + <gl-dropdown + v-if="isProjectsImportEnabled" + :text="s__('BulkImport|Import with projects')" + :disabled="!hasSelectedGroups" + variant="confirm" + category="primary" + class="gl-ml-4" + split + @click="importSelectedGroups({ migrateProjects: true })" + > + <gl-dropdown-item @click="importSelectedGroups({ migrateProjects: false })"> + {{ s__('BulkImport|Import without projects') }} + </gl-dropdown-item> + </gl-dropdown> <gl-button + v-else category="primary" variant="confirm" class="gl-ml-4" @@ -654,6 +681,22 @@ export default { @click="importSelectedGroups" >{{ s__('BulkImport|Import selected') }}</gl-button > + <span class="gl-ml-3"> + <gl-icon name="information-o" :size="12" class="gl-text-blue-600" /> + <gl-sprintf + :message=" + s__( + 'BulkImport|Importing projects is a %{docsLinkStart}Beta%{docsLinkEnd} feature.', + ) + " + > + <template #docsLink="{ content }" + ><gl-link :href="$options.betaFeatureHelpPath" target="_blank">{{ + content + }}</gl-link></template + > + </gl-sprintf> + </span> </div> <gl-table ref="table" @@ -661,6 +704,7 @@ export default { data-qa-selector="import_table" :tbody-tr-class="rowClasses" :tbody-tr-attr="qaRowAttributes" + thead-class="gl-sticky gl-z-index-2 gl-bg-gray-10" :items="groupsTableData" :fields="$options.fields" selectable @@ -711,6 +755,7 @@ export default { </template> <template #cell(actions)="{ item: group }"> <import-actions-cell + :is-projects-import-enabled="isProjectsImportEnabled" :is-finished="group.flags.isFinished" :is-available-for-import="group.flags.isAvailableForImport" :is-invalid="group.flags.isInvalid" @@ -720,6 +765,7 @@ export default { sourceGroupId: group.id, targetNamespace: group.importTarget.targetNamespace.fullPath, newName: group.importTarget.newName, + ...$event, }, ]) " 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 913a5a659b3..de0595360bf 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 @@ -153,6 +153,7 @@ export function createResolvers({ endpoints }) { source_full_path: op.group.fullPath, destination_namespace: op.targetNamespace, destination_name: op.newName, + migrate_projects: op.migrateProjects, })), }); diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql index c48e22a7717..83d17a5baa7 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql +++ b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql @@ -74,6 +74,7 @@ input ImportRequestInput { sourceGroupId: ID! targetNamespace: String! newName: String! + migrateProjects: Boolean! } extend type Mutation { 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 b8faf349375..da5dcfa71e3 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 @@ -140,7 +140,7 @@ export default { > <template v-if="repo.importSource.target">{{ repo.importSource.target }}</template> <template v-else-if="isImportNotStarted"> - <div class="import-entities-target-select gl-display-flex gl-align-items-stretch gl-w-full"> + <div class="gl-display-flex gl-align-items-stretch gl-w-full"> <import-group-dropdown #default="{ namespaces }" :text="importTarget.targetNamespace"> <template v-if="namespaces.length"> <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header> @@ -161,7 +161,7 @@ export default { }}</gl-dropdown-item> </import-group-dropdown> <div - class="import-entities-target-select-separator gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1" + class="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> diff --git a/app/assets/javascripts/import_entities/import_projects/index.js b/app/assets/javascripts/import_entities/import_projects/index.js index 197fb03af2c..485511510f7 100644 --- a/app/assets/javascripts/import_entities/import_projects/index.js +++ b/app/assets/javascripts/import_entities/import_projects/index.js @@ -57,7 +57,11 @@ const apolloProvider = new VueApollo({ defaultClient, }); -export default function mountImportProjectsTable(mountElement) { +export default function mountImportProjectsTable({ + mountElement, + Component = ImportProjectsTable, + extraProps = () => ({}), +}) { if (!mountElement) return undefined; const store = initStoreFromElement(mountElement); @@ -68,7 +72,7 @@ export default function mountImportProjectsTable(mountElement) { store, apolloProvider, render(createElement) { - return createElement(ImportProjectsTable, { props }); + return createElement(Component, { props: { ...props, ...extraProps(mountElement.dataset) } }); }, }); } diff --git a/app/assets/javascripts/init_diff_stats_dropdown.js b/app/assets/javascripts/init_diff_stats_dropdown.js index 27df761a103..8413fe92f89 100644 --- a/app/assets/javascripts/init_diff_stats_dropdown.js +++ b/app/assets/javascripts/init_diff_stats_dropdown.js @@ -4,7 +4,7 @@ import { stickyMonitor } from './lib/utils/sticky'; export const initDiffStatsDropdown = (stickyTop) => { if (stickyTop) { - stickyMonitor(document.querySelector('.js-diff-files-changed'), stickyTop); + stickyMonitor(document.querySelector('.js-diff-files-changed'), stickyTop, false); } const el = document.querySelector('.js-diff-stats-dropdown'); diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js index 392dd63b089..b956bdf067d 100644 --- a/app/assets/javascripts/integrations/constants.js +++ b/app/assets/javascripts/integrations/constants.js @@ -52,6 +52,7 @@ export const integrationTriggerEvents = { TAG_PUSH: 'tag_push_events', PIPELINE: 'pipeline_events', WIKI_PAGE: 'wiki_page_events', + DEPLOYMENT: 'deployment_events', }; export const integrationTriggerEventTitles = { @@ -72,6 +73,9 @@ export const integrationTriggerEventTitles = { [integrationTriggerEvents.TAG_PUSH]: s__('IntegrationEvents|A tag is pushed to the repository'), [integrationTriggerEvents.PIPELINE]: s__('IntegrationEvents|A pipeline status changes'), [integrationTriggerEvents.WIKI_PAGE]: s__('IntegrationEvents|A wiki page is created or updated'), + [integrationTriggerEvents.DEPLOYMENT]: s__( + 'IntegrationEvents|A deployment is started or finished', + ), }; export const billingPlans = { diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index d86e6326f64..1e58b604bf7 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -1,5 +1,5 @@ <script> -import { GlAlert, GlBadge, GlButton, GlForm } from '@gitlab/ui'; +import { GlAlert, GlForm } from '@gitlab/ui'; import axios from 'axios'; import * as Sentry from '@sentry/browser'; import { mapState, mapActions, mapGetters } from 'vuex'; @@ -10,8 +10,6 @@ import { I18N_DEFAULT_ERROR_MESSAGE, I18N_SUCCESSFUL_CONNECTION_MESSAGE, INTEGRATION_FORM_TYPE_SLACK, - integrationFormSectionComponents, - billingPlanNames, } from '~/integrations/constants'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; import csrf from '~/lib/utils/csrf'; @@ -21,6 +19,7 @@ import ActiveCheckbox from './active_checkbox.vue'; import DynamicField from './dynamic_field.vue'; import OverrideDropdown from './override_dropdown.vue'; import TriggerFields from './trigger_fields.vue'; +import IntegrationFormSection from './integration_forms/section.vue'; import IntegrationFormActions from './integration_form_actions.vue'; export default { @@ -31,29 +30,8 @@ export default { TriggerFields, DynamicField, IntegrationFormActions, - IntegrationSectionConfiguration: () => - import( - /* webpackChunkName: 'integrationSectionConfiguration' */ '~/integrations/edit/components/sections/configuration.vue' - ), - IntegrationSectionConnection: () => - import( - /* webpackChunkName: 'integrationSectionConnection' */ '~/integrations/edit/components/sections/connection.vue' - ), - IntegrationSectionJiraIssues: () => - import( - /* webpackChunkName: 'integrationSectionJiraIssues' */ '~/integrations/edit/components/sections/jira_issues.vue' - ), - IntegrationSectionJiraTrigger: () => - import( - /* webpackChunkName: 'integrationSectionJiraTrigger' */ '~/integrations/edit/components/sections/jira_trigger.vue' - ), - IntegrationSectionTrigger: () => - import( - /* webpackChunkName: 'integrationSectionTrigger' */ '~/integrations/edit/components/sections/trigger.vue' - ), + IntegrationFormSection, GlAlert, - GlBadge, - GlButton, GlForm, }, directives: { @@ -120,9 +98,6 @@ export default { }, methods: { ...mapActions(['setOverride', 'requestJiraIssueTypes']), - fieldsForSection(section) { - return this.propsSource.fields.filter((field) => field.section === section.type); - }, form() { return this.$refs.integrationForm.$el; }, @@ -189,23 +164,21 @@ export default { this.isResetting = false; }); }, - onRequestJiraIssueTypes() { - this.requestJiraIssueTypes(this.getFormData()); - }, getFormData() { return new FormData(this.form()); }, onToggleIntegrationState(integrationActive) { this.integrationActive = integrationActive; }, + onRequestJiraIssueTypes() { + this.requestJiraIssueTypes(this.getFormData()); + }, }, helpHtmlConfig: { ADD_TAGS: ['use'], // to support icon SVGs FORBID_ATTR: [], // This is trusted input so we can override the default config to allow data-* attributes }, csrf, - integrationFormSectionComponents, - billingPlanNames, slackUpgradeInfo: { title: s__( `SlackIntegration|Update to the latest version of GitLab for Slack to get notifications`, @@ -280,42 +253,15 @@ export default { </div> <template v-if="hasSections"> - <div + <integration-form-section v-for="(section, index) in customState.sections" :key="section.type" + :section="section" + :is-validated="isValidated" :class="{ 'gl-border-b gl-pb-3 gl-mb-6': index !== customState.sections.length - 1 }" - data-testid="integration-section" - > - <section class="gl-lg-display-flex"> - <div class="gl-flex-basis-third gl-mr-4"> - <h4 class="gl-mt-0"> - {{ section.title - }}<gl-badge - v-if="section.plan" - :href="propsSource.aboutPricingUrl" - target="_blank" - rel="noopener noreferrer" - variant="tier" - icon="license" - class="gl-ml-3" - > - {{ $options.billingPlanNames[section.plan] }} - </gl-badge> - </h4> - <p v-safe-html="section.description"></p> - </div> - - <div class="gl-flex-basis-two-thirds"> - <component - :is="$options.integrationFormSectionComponents[section.type]" - :fields="fieldsForSection(section)" - :is-validated="isValidated" - @toggle-integration-active="onToggleIntegrationState" - @request-jira-issue-types="onRequestJiraIssueTypes" - /> - </div> - </section> - </div> + @toggle-integration-active="onToggleIntegrationState" + @request-jira-issue-types="onRequestJiraIssueTypes" + /> </template> <section v-if="hasFieldsWithoutSection" class="gl-lg-display-flex gl-justify-content-end"> diff --git a/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue b/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue new file mode 100644 index 00000000000..ce39954735a --- /dev/null +++ b/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue @@ -0,0 +1,90 @@ +<script> +import { GlBadge } from '@gitlab/ui'; +import { mapGetters } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { integrationFormSectionComponents, billingPlanNames } from '~/integrations/constants'; + +export default { + name: 'IntegrationFormSection', + components: { + GlBadge, + IntegrationSectionConfiguration: () => + import( + /* webpackChunkName: 'integrationSectionConfiguration' */ '~/integrations/edit/components/sections/configuration.vue' + ), + IntegrationSectionConnection: () => + import( + /* webpackChunkName: 'integrationSectionConnection' */ '~/integrations/edit/components/sections/connection.vue' + ), + IntegrationSectionJiraIssues: () => + import( + /* webpackChunkName: 'integrationSectionJiraIssues' */ '~/integrations/edit/components/sections/jira_issues.vue' + ), + IntegrationSectionJiraTrigger: () => + import( + /* webpackChunkName: 'integrationSectionJiraTrigger' */ '~/integrations/edit/components/sections/jira_trigger.vue' + ), + IntegrationSectionTrigger: () => + import( + /* webpackChunkName: 'integrationSectionTrigger' */ '~/integrations/edit/components/sections/trigger.vue' + ), + }, + directives: { + SafeHtml, + }, + props: { + section: { + type: Object, + required: true, + }, + isValidated: { + type: Boolean, + required: true, + }, + }, + computed: { + ...mapGetters(['propsSource']), + }, + methods: { + fieldsForSection(section) { + return this.propsSource.fields.filter((field) => field.section === section.type); + }, + }, + billingPlanNames, + integrationFormSectionComponents, +}; +</script> +<template> + <section class="gl-lg-display-flex"> + <div class="gl-flex-basis-third gl-mr-4"> + <h4 class="gl-mt-0"> + {{ section.title + }}<gl-badge + v-if="section.plan" + :href="propsSource.aboutPricingUrl" + target="_blank" + rel="noopener noreferrer" + variant="tier" + icon="license" + class="gl-ml-3" + > + {{ $options.billingPlanNames[section.plan] }} + </gl-badge> + </h4> + <p v-safe-html="section.description"></p> + </div> + + <div + v-if="$options.integrationFormSectionComponents[section.type]" + class="gl-flex-basis-two-thirds" + > + <component + :is="$options.integrationFormSectionComponents[section.type]" + :fields="fieldsForSection(section)" + :is-validated="isValidated" + @toggle-integration-active="$emit('toggle-integration-active', $event)" + @request-jira-issue-types="$emit('request-jira-issue-types', $event)" + /> + </div> + </section> +</template> diff --git a/app/assets/javascripts/integrations/edit/components/sections/trigger.vue b/app/assets/javascripts/integrations/edit/components/sections/trigger.vue index 9af5070d4cf..00546671aa7 100644 --- a/app/assets/javascripts/integrations/edit/components/sections/trigger.vue +++ b/app/assets/javascripts/integrations/edit/components/sections/trigger.vue @@ -15,11 +15,12 @@ export default { </script> <template> - <div> + <div data-testid="trigger-fields-group"> <trigger-field v-for="event in propsSource.triggerEvents" :key="`${currentKey}-trigger-fields-${event.name}`" :event="event" + :type="propsSource.type" class="gl-mb-3" /> </div> diff --git a/app/assets/javascripts/integrations/edit/components/trigger_field.vue b/app/assets/javascripts/integrations/edit/components/trigger_field.vue index dc5ae2f3a3d..57753c61587 100644 --- a/app/assets/javascripts/integrations/edit/components/trigger_field.vue +++ b/app/assets/javascripts/integrations/edit/components/trigger_field.vue @@ -1,13 +1,17 @@ <script> -import { GlFormCheckbox } from '@gitlab/ui'; +import { GlFormCheckbox, GlFormInput } from '@gitlab/ui'; import { mapGetters } from 'vuex'; -import { integrationTriggerEventTitles } from '~/integrations/constants'; +import { + placeholderForType, + integrationTriggerEventTitles, +} from 'any_else_ce/integrations/constants'; export default { name: 'TriggerField', components: { GlFormCheckbox, + GlFormInput, }, props: { event: { @@ -15,10 +19,15 @@ export default { required: false, default: () => ({}), }, + type: { + type: String, + required: true, + }, }, data() { return { value: false, + fieldValue: this.event.field?.value, }; }, computed: { @@ -26,9 +35,15 @@ export default { name() { return `service[${this.event.name}]`; }, + fieldName() { + return `service[${this.event.field?.name}]`; + }, title() { return integrationTriggerEventTitles[this.event.name]; }, + defaultPlaceholder() { + return placeholderForType[this.type]; + }, }, mounted() { this.value = this.event.value || false; @@ -42,5 +57,15 @@ export default { <gl-form-checkbox v-model="value" :disabled="isInheriting"> {{ title }} </gl-form-checkbox> + <div class="gl-ml-6"> + <gl-form-input + v-if="event.field" + v-show="value" + v-model="fieldValue" + :name="fieldName" + :placeholder="event.field.placeholder || defaultPlaceholder" + :readonly="isInheriting" + /> + </div> </div> </template> 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 fbb547c28ff..fa1aa6b0d88 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -18,8 +18,6 @@ import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { getParameterValues } from '~/lib/utils/url_utility'; import { n__, sprintf } from '~/locale'; import { - CLOSE_TO_LIMIT_VARIANT, - REACHED_LIMIT_VARIANT, USERS_FILTER_ALL, INVITE_MEMBERS_FOR_TASK, MEMBER_MODAL_LABELS, @@ -189,10 +187,10 @@ export default { return this.source === LEARN_GITLAB; }, showUserLimitNotification() { - return this.usersLimitDataset.reachedLimit || this.usersLimitDataset.closeToDashboardLimit; + return !isEmpty(this.usersLimitDataset.alertVariant); }, limitVariant() { - return this.usersLimitDataset.reachedLimit ? REACHED_LIMIT_VARIANT : CLOSE_TO_LIMIT_VARIANT; + return this.usersLimitDataset.alertVariant; }, errorList() { return Object.entries(this.invalidMembers).map(([member, error]) => { @@ -479,6 +477,7 @@ export default { </gl-alert> <user-limit-notification v-else-if="showUserLimitNotification" + class="gl-mb-5" :limit-variant="limitVariant" :users-limit-dataset="usersLimitDataset" /> diff --git a/app/assets/javascripts/invite_members/components/user_limit_notification.vue b/app/assets/javascripts/invite_members/components/user_limit_notification.vue index 515dd3de319..1d061a4b81e 100644 --- a/app/assets/javascripts/invite_members/components/user_limit_notification.vue +++ b/app/assets/javascripts/invite_members/components/user_limit_notification.vue @@ -1,14 +1,18 @@ <script> import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; import { n__, sprintf } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { + INFO_ALERT_TITLE, WARNING_ALERT_TITLE, DANGER_ALERT_TITLE, REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE, REACHED_LIMIT_VARIANT, CLOSE_TO_LIMIT_MESSAGE, CLOSE_TO_LIMIT_VARIANT, + NOTIFICATION_LIMIT_MESSAGE, + NOTIFICATION_LIMIT_VARIANT, } from '../constants'; export default { @@ -28,6 +32,15 @@ export default { computed: { limitAttributes() { return { + [NOTIFICATION_LIMIT_VARIANT]: { + variant: 'info', + title: this.notificationTitle( + INFO_ALERT_TITLE, + this.name, + this.usersLimitDataset.freeUsersLimit, + ), + message: this.message(NOTIFICATION_LIMIT_MESSAGE, this.usersLimitDataset.freeUsersLimit), + }, [CLOSE_TO_LIMIT_VARIANT]: { variant: 'warning', title: this.title(WARNING_ALERT_TITLE, this.usersLimitDataset.remainingSeats), @@ -42,6 +55,13 @@ export default { }, }, methods: { + notificationTitle(titleTemplate, namespaceName, dashboardLimit) { + return sprintf(titleTemplate, { + namespaceName, + dashboardLimit, + }); + }, + title(titleTemplate, count) { return sprintf(titleTemplate, { count, @@ -49,7 +69,14 @@ export default { name: this.name, }); }, + + message(messageTemplate, dashboardLimit) { + return sprintf(messageTemplate, { + dashboardLimit, + }); + }, }, + freeUserLimitHelpPath: helpPagePath('user/free_user_limit'), }; </script> @@ -60,6 +87,11 @@ export default { :title="limitAttributes[limitVariant].title" > <gl-sprintf :message="limitAttributes[limitVariant].message"> + <template #freeUserLimitLink="{ content }"> + <gl-link :href="$options.freeUserLimitHelpPath" class="gl-label-link">{{ + content + }}</gl-link> + </template> <template #trialLink="{ content }"> <gl-link :href="usersLimitDataset.newTrialRegistrationPath" diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index a894eb24d38..edc0ebff083 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -139,6 +139,9 @@ export const GROUP_MODAL_LABELS = { export const LEARN_GITLAB = 'learn_gitlab'; export const ON_SHOW_TRACK_LABEL = 'over_limit_modal_viewed'; +export const INFO_ALERT_TITLE = s__( + 'InviteMembersModal|Your top-level group %{namespaceName} is over the %{dashboardLimit} user limit.', +); export const WARNING_ALERT_TITLE = s__( 'InviteMembersModal|You only have space for %{count} more %{members} in %{name}', ); @@ -148,17 +151,22 @@ export const DANGER_ALERT_TITLE = s__( export const REACHED_LIMIT_VARIANT = 'reached'; export const CLOSE_TO_LIMIT_VARIANT = 'close'; +export const NOTIFICATION_LIMIT_VARIANT = 'notification'; export const REACHED_LIMIT_MESSAGE = s__( - 'InviteMembersModal|To invite new users to this namespace, you must remove existing users. You can still add existing namespace users.', + 'InviteMembersModal|To invite new users to this top-level group, you must remove existing users. You can still add existing users from the top-level group, including any subgroups and projects.', ); export const REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE = REACHED_LIMIT_MESSAGE.concat( s__( - 'InviteMembersModal| To get more members, the owner of this namespace can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier.', + 'InviteMembersModal| To get more members, the owner of this top-level group can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier.', ), ); export const CLOSE_TO_LIMIT_MESSAGE = s__( 'InviteMembersModal|To get more members an owner of the group can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier.', ); + +export const NOTIFICATION_LIMIT_MESSAGE = s__( + 'InviteMembersModal|GitLab will enforce this limit in the future. If you are over %{dashboardLimit} users when enforcement begins, your top-level group will be placed in a %{freeUserLimitLinkStart}read-only state%{freeUserLimitLinkEnd}. To avoid being placed in a read-only state, reduce your top-level group to %{dashboardLimit} users or less, or purchase a paid tier.', +); diff --git a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue index 543dca0afe1..14325d6b64e 100644 --- a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue +++ b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue @@ -1,11 +1,16 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { mapGetters } from 'vuex'; -import { __ } from '~/locale'; +import { sprintf, __ } from '~/locale'; import { IssuableType, WorkspaceType } from '~/issues/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; +const NoteableTypeText = { + issue: __('issue'), + merge_request: __('merge request'), +}; + export default { WorkspaceType, IssuableType, @@ -40,7 +45,9 @@ export default { iconName: 'spam', visible: this.hidden, dataTestId: 'hidden', - tooltip: __('This issue is hidden because its author has been banned'), + tooltip: sprintf(__('This %{issuable} is hidden because its author has been banned'), { + issuable: NoteableTypeText[this.getNoteableData.targetType], + }), }, ]; }, diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue index fd55f05e955..c815c7aaba9 100644 --- a/app/assets/javascripts/issuable/components/related_issuable_item.vue +++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue @@ -135,7 +135,6 @@ export default { <gl-link :href="computedPath" class="sortable-link gl-font-weight-normal" - target="_blank" @click="handleTitleClick" > {{ title }} diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js index e8ba99e0e9e..99a3f76ca76 100644 --- a/app/assets/javascripts/issuable/issuable_form.js +++ b/app/assets/javascripts/issuable/issuable_form.js @@ -47,13 +47,12 @@ function getFallbackKey() { } export default class IssuableForm { - static addAutosave(map, id, $input, searchTerm, fallbackKey) { - if ($input.length) { - map.set( - id, - new Autosave($input, [document.location.pathname, searchTerm, id], `${fallbackKey}=${id}`), - ); - } + static addAutosave(map, id, element, searchTerm, fallbackKey) { + if (!element) return; + map.set( + id, + new Autosave(element, [document.location.pathname, searchTerm, id], `${fallbackKey}=${id}`), + ); } constructor(form) { @@ -122,28 +121,28 @@ export default class IssuableForm { IssuableForm.addAutosave( autosaveMap, 'title', - this.form.find('input[name*="[title]"]'), + this.form.find('input[name*="[title]"]').get(0), this.searchTerm, this.fallbackKey, ); IssuableForm.addAutosave( autosaveMap, 'description', - this.form.find('textarea[name*="[description]"]'), + this.form.find('textarea[name*="[description]"]').get(0), this.searchTerm, this.fallbackKey, ); IssuableForm.addAutosave( autosaveMap, 'confidential', - this.form.find('input:checkbox[name*="[confidential]"]'), + this.form.find('input:checkbox[name*="[confidential]"]').get(0), this.searchTerm, this.fallbackKey, ); IssuableForm.addAutosave( autosaveMap, 'due_date', - this.form.find('input[name*="[due_date]"]'), + this.form.find('input[name*="[due_date]"]').get(0), this.searchTerm, this.fallbackKey, ); diff --git a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue index b9d876ef72f..8edc9a08c9e 100644 --- a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue +++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue @@ -30,20 +30,35 @@ import { __ } from '~/locale'; import { TOKEN_TITLE_ASSIGNEE, TOKEN_TITLE_AUTHOR, + TOKEN_TITLE_LABEL, + TOKEN_TITLE_MILESTONE, + TOKEN_TITLE_MY_REACTION, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_MY_REACTION, } from '~/vue_shared/components/filtered_search_bar/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants'; +import { AutocompleteCache } from '../utils'; const UserToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'); +const EmojiToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'); +const LabelToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'); +const MilestoneToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'); export default { i18n: { calendarButtonText: __('Subscribe to calendar'), closed: __('CLOSED'), closedMoved: __('CLOSED (MOVED)'), - emptyStateTitle: __('Please select at least one filter to see results'), + emptyStateWithFilterTitle: __('Sorry, your filter produced no results'), + emptyStateWithFilterDescription: __('To widen your search, change or remove filters above'), + emptyStateWithoutFilterTitle: __('Please select at least one filter to see results'), errorFetchingIssues: __('An error occurred while loading issues'), rssButtonText: __('Subscribe to RSS feed'), searchInputPlaceholder: __('Search or filter results...'), @@ -60,8 +75,12 @@ export default { GlTooltip: GlTooltipDirective, }, inject: [ + 'autocompleteAwardEmojisPath', 'calendarPath', - 'emptyStateSvgPath', + 'dashboardLabelsPath', + 'dashboardMilestonesPath', + 'emptyStateWithFilterSvgPath', + 'emptyStateWithoutFilterSvgPath', 'hasBlockedIssuesFeature', 'hasIssuableHealthStatusFeature', 'hasIssueWeightsFeature', @@ -117,6 +136,9 @@ export default { this.issuesError = this.$options.i18n.errorFetchingIssues; Sentry.captureException(error); }, + skip() { + return !this.hasSearch; + }, debounce: 200, }, }, @@ -124,6 +146,25 @@ export default { apiFilterParams() { return convertToApiParams(this.filterTokens); }, + emptyStateDescription() { + return this.hasSearch ? this.$options.i18n.emptyStateWithFilterDescription : undefined; + }, + emptyStateSvgPath() { + return this.hasSearch + ? this.emptyStateWithFilterSvgPath + : this.emptyStateWithoutFilterSvgPath; + }, + emptyStateTitle() { + return this.hasSearch + ? this.$options.i18n.emptyStateWithFilterTitle + : this.$options.i18n.emptyStateWithoutFilterTitle; + }, + hasSearch() { + return Boolean(this.searchQuery || Object.keys(this.urlFilterParams).length); + }, + renderedIssues() { + return this.hasSearch ? this.issues : []; + }, searchQuery() { return convertToSearchQuery(this.filterTokens); }, @@ -159,12 +200,46 @@ export default { preloadedUsers, recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-author', }, + { + type: TOKEN_TYPE_LABEL, + title: TOKEN_TITLE_LABEL, + icon: 'labels', + token: LabelToken, + fetchLabels: this.fetchLabels, + recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-label', + }, + { + type: TOKEN_TYPE_MILESTONE, + title: TOKEN_TITLE_MILESTONE, + icon: 'clock', + token: MilestoneToken, + fetchMilestones: this.fetchMilestones, + recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-milestone', + shouldSkipSort: true, + }, ]; + if (this.isSignedIn) { + tokens.push({ + type: TOKEN_TYPE_MY_REACTION, + title: TOKEN_TITLE_MY_REACTION, + icon: 'thumb-up', + token: EmojiToken, + unique: true, + fetchEmojis: this.fetchEmojis, + recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-my_reaction', + }); + } + + tokens.sort((a, b) => a.title.localeCompare(b.title)); + return tokens; }, showPaginationControls() { - return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage); + return ( + this.renderedIssues.length > 0 && + (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage) + ); }, sortOptions() { return getSortOptions({ @@ -185,7 +260,34 @@ export default { }; }, }, + created() { + this.autocompleteCache = new AutocompleteCache(); + }, methods: { + fetchEmojis(search) { + return this.autocompleteCache.fetch({ + url: this.autocompleteAwardEmojisPath, + cacheName: 'emojis', + searchProperty: 'name', + search, + }); + }, + fetchLabels(search) { + return this.autocompleteCache.fetch({ + url: this.dashboardLabelsPath, + cacheName: 'labels', + searchProperty: 'title', + search, + }); + }, + fetchMilestones(search) { + return this.autocompleteCache.fetch({ + url: this.dashboardMilestonesPath, + cacheName: 'milestones', + searchProperty: 'title', + search, + }); + }, fetchUsers(search) { return axios.get('/-/autocomplete/users.json', { params: { active: true, search } }); }, @@ -266,7 +368,7 @@ export default { :has-scoped-labels-feature="hasScopedLabelsFeature" :initial-filter-value="filterTokens" :initial-sort-by="sortKey" - :issuables="issues" + :issuables="renderedIssues" :issuables-loading="$apollo.queries.issues.loading" namespace="dashboard" recent-searches-storage-key="issues" @@ -307,7 +409,11 @@ export default { </template> <template #empty-state> - <gl-empty-state :svg-path="emptyStateSvgPath" :title="$options.i18n.emptyStateTitle" /> + <gl-empty-state + :description="emptyStateDescription" + :svg-path="emptyStateSvgPath" + :title="emptyStateTitle" + /> </template> </issuable-list> </template> diff --git a/app/assets/javascripts/issues/dashboard/index.js b/app/assets/javascripts/issues/dashboard/index.js index e3e5cc614cb..005ab5ce3b0 100644 --- a/app/assets/javascripts/issues/dashboard/index.js +++ b/app/assets/javascripts/issues/dashboard/index.js @@ -14,8 +14,12 @@ export function mountIssuesDashboardApp() { Vue.use(VueApollo); const { + autocompleteAwardEmojisPath, calendarPath, - emptyStateSvgPath, + dashboardLabelsPath, + dashboardMilestonesPath, + emptyStateWithFilterSvgPath, + emptyStateWithoutFilterSvgPath, hasBlockedIssuesFeature, hasIssuableHealthStatusFeature, hasIssueWeightsFeature, @@ -33,8 +37,12 @@ export function mountIssuesDashboardApp() { defaultClient: createDefaultClient(), }), provide: { + autocompleteAwardEmojisPath, calendarPath, - emptyStateSvgPath, + dashboardLabelsPath, + dashboardMilestonesPath, + emptyStateWithFilterSvgPath, + emptyStateWithoutFilterSvgPath, hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature), hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), diff --git a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql index 8ffcb456755..43b8804108c 100644 --- a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql @@ -7,8 +7,14 @@ query getDashboardIssues( $search: String $sort: IssueSort $state: IssuableState + $assigneeId: String $assigneeUsernames: [String!] $authorUsername: String + $labelName: [String] + $milestoneTitle: [String] + $milestoneWildcardId: MilestoneWildcardId + $myReactionEmoji: String + $not: NegatedIssueFilterInput $afterCursor: String $beforeCursor: String $firstPageSize: Int @@ -18,8 +24,14 @@ query getDashboardIssues( search: $search sort: $sort state: $state + assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames authorUsername: $authorUsername + labelName: $labelName + milestoneTitle: $milestoneTitle + milestoneWildcardId: $milestoneWildcardId + myReactionEmoji: $myReactionEmoji + not: $not after: $afterCursor before: $beforeCursor first: $firstPageSize diff --git a/app/assets/javascripts/issues/dashboard/utils.js b/app/assets/javascripts/issues/dashboard/utils.js new file mode 100644 index 00000000000..6fa95b38649 --- /dev/null +++ b/app/assets/javascripts/issues/dashboard/utils.js @@ -0,0 +1,23 @@ +import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import { MAX_LIST_SIZE } from '~/issues/list/constants'; +import axios from '~/lib/utils/axios_utils'; + +export class AutocompleteCache { + constructor() { + this.cache = {}; + } + + fetch({ url, cacheName, searchProperty, search }) { + if (this.cache[cacheName]) { + const data = search + ? fuzzaldrinPlus.filter(this.cache[cacheName], search, { key: searchProperty }) + : this.cache[cacheName].slice(0, MAX_LIST_SIZE); + return Promise.resolve(data); + } + + return axios.get(url).then(({ data }) => { + this.cache[cacheName] = data; + return data.slice(0, MAX_LIST_SIZE); + }); + } +} 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 12a83f06453..e4000184f41 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -352,6 +352,7 @@ export default { title: TOKEN_TITLE_LABEL, icon: 'labels', token: LabelToken, + operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT, fetchLabels: this.fetchLabels, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-label`, }, diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index 49a953cad43..87184799d5f 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -159,7 +159,7 @@ export const TYPE_TOKEN_OBJECTIVE_OPTION = { }; export const TYPE_TOKEN_KEY_RESULT_OPTION = { - icon: 'issue-type-key-result', + icon: 'issue-type-keyresult', title: 'key_result', value: 'key_result', }; @@ -247,6 +247,7 @@ export const filters = { [API_PARAM]: { [NORMAL_FILTER]: 'labelName', [SPECIAL_FILTER]: 'labelName', + [ALTERNATIVE_FILTER]: 'labelNames', }, [URL_PARAM]: { [OPERATOR_IS]: { @@ -257,6 +258,9 @@ export const filters = { [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[label_name][]', }, + [OPERATOR_OR]: { + [ALTERNATIVE_FILTER]: 'or[label_name][]', + }, }, }, [TOKEN_TYPE_TYPE]: { @@ -360,14 +364,17 @@ export const filters = { }, [TOKEN_TYPE_HEALTH]: { [API_PARAM]: { - [NORMAL_FILTER]: 'healthStatus', - [SPECIAL_FILTER]: 'healthStatus', + [NORMAL_FILTER]: 'healthStatusFilter', + [SPECIAL_FILTER]: 'healthStatusFilter', }, [URL_PARAM]: { [OPERATOR_IS]: { [NORMAL_FILTER]: 'health_status', [SPECIAL_FILTER]: 'health_status', }, + [OPERATOR_NOT]: { + [NORMAL_FILTER]: 'not[health_status]', + }, }, }, [TOKEN_TYPE_CONTACT]: { diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js index b566e08731c..bbd081843ca 100644 --- a/app/assets/javascripts/issues/list/utils.js +++ b/app/assets/javascripts/issues/list/utils.js @@ -13,6 +13,8 @@ import { TOKEN_TYPE_MILESTONE, TOKEN_TYPE_RELEASE, TOKEN_TYPE_TYPE, + TOKEN_TYPE_HEALTH, + TOKEN_TYPE_LABEL, } from '~/vue_shared/components/filtered_search_bar/constants'; import { ALTERNATIVE_FILTER, @@ -252,8 +254,9 @@ const isSpecialFilter = (type, data) => { const getFilterType = ({ type, value: { data, operator } }) => { const isUnionedAuthor = type === TOKEN_TYPE_AUTHOR && operator === OPERATOR_OR; + const isUnionedLabel = type === TOKEN_TYPE_LABEL && operator === OPERATOR_OR; - if (isUnionedAuthor) { + if (isUnionedAuthor || isUnionedLabel) { return ALTERNATIVE_FILTER; } if (isSpecialFilter(type, data)) { @@ -267,8 +270,13 @@ const wildcardTokens = [TOKEN_TYPE_ITERATION, TOKEN_TYPE_MILESTONE, TOKEN_TYPE_R const isWildcardValue = (tokenType, value) => wildcardTokens.includes(tokenType) && specialFilterValues.includes(value); +const isHealthStatusSpecialFilter = (tokenType, value) => + tokenType === TOKEN_TYPE_HEALTH && specialFilterValues.includes(value); + const requiresUpperCaseValue = (tokenType, value) => - tokenType === TOKEN_TYPE_TYPE || isWildcardValue(tokenType, value); + tokenType === TOKEN_TYPE_TYPE || + isWildcardValue(tokenType, value) || + isHealthStatusSpecialFilter(tokenType, value); const formatData = (token) => { if (requiresUpperCaseValue(token.type, token.value.data)) { diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index 983e2e6530e..56e360c75e3 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -19,6 +19,7 @@ import { visitUrl } from '~/lib/utils/url_utility'; import { s__, __, sprintf } from '~/locale'; import eventHub from '~/notes/event_hub'; import Tracking from '~/tracking'; +import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; import promoteToEpicMutation from '../queries/promote_to_epic.mutation.graphql'; import updateIssueMutation from '../queries/update_issue.mutation.graphql'; import DeleteIssueModal from './delete_issue_modal.vue'; @@ -50,6 +51,7 @@ export default { GlDropdownItem, GlLink, GlModal, + AbuseCategorySelector, }, directives: { GlModal: GlModalDirective, @@ -93,13 +95,15 @@ export default { projectPath: { default: '', }, - reportAbusePath: { - default: '', - }, submitAsSpamPath: { default: '', }, }, + data() { + return { + isReportAbuseDrawerOpen: false, + }; + }, computed: { ...mapState(['isToggleStateButtonLoading']), ...mapGetters(['openState', 'getBlockedByIssues']), @@ -163,6 +167,9 @@ export default { this.invokeUpdateIssueMutation(); }, + toggleReportAbuseDrawer(isOpen) { + this.isReportAbuseDrawerOpen = isOpen; + }, invokeUpdateIssueMutation() { this.toggleStateButtonLoading(true); @@ -255,7 +262,7 @@ export default { <gl-dropdown-item v-if="canPromoteToEpic" @click="promoteToEpic"> {{ __('Promote to epic') }} </gl-dropdown-item> - <gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath"> + <gl-dropdown-item v-if="!isIssueAuthor" @click="toggleReportAbuseDrawer(true)"> {{ $options.i18n.reportAbuse }} </gl-dropdown-item> <gl-dropdown-item @@ -314,7 +321,7 @@ export default { > {{ __('Promote to epic') }} </gl-dropdown-item> - <gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath"> + <gl-dropdown-item v-if="!isIssueAuthor" @click="toggleReportAbuseDrawer(true)"> {{ $options.i18n.reportAbuse }} </gl-dropdown-item> <gl-dropdown-item @@ -360,5 +367,10 @@ export default { :modal-id="$options.deleteModalId" :title="deleteButtonText" /> + + <abuse-category-selector + :show-drawer="isReportAbuseDrawerOpen" + @close-drawer="toggleReportAbuseDrawer(false)" + /> </div> </template> diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js index 22db19610c1..2fdae538902 100644 --- a/app/assets/javascripts/issues/show/components/incidents/constants.js +++ b/app/assets/javascripts/issues/show/components/incidents/constants.js @@ -12,6 +12,9 @@ export const timelineFormI18n = Object.freeze({ 'Incident|Something went wrong while creating the incident timeline event.', ), areaPlaceholder: s__('Incident|Timeline text...'), + areaDefaultMessage: s__('Incident|Incident'), + selectTags: __('Select tags'), + tagsLabel: __('Event tag (optional)'), save: __('Save'), cancel: __('Cancel'), delete: __('Delete'), @@ -42,4 +45,14 @@ export const timelineItemI18n = Object.freeze({ timeUTC: __('%{time} UTC'), }); +export const timelineEventTagsI18n = Object.freeze({ + startTime: __('Start time'), + endTime: __('End time'), +}); + export const MAX_TEXT_LENGTH = 280; + +export const TIMELINE_EVENT_TAGS = Object.values(timelineEventTagsI18n).map((item) => ({ + text: item, + value: item, +})); diff --git a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue index 6bb72e82778..81111d42b39 100644 --- a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue +++ b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue @@ -74,6 +74,7 @@ export default { incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId), note: eventDetails.note, occurredAt: eventDetails.occurredAt, + timelineEventTagNames: eventDetails.timelineEventTags, }, }, update: this.updateCache, diff --git a/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue index 8cdd62ca9ef..4ef9b9c5a99 100644 --- a/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue +++ b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue @@ -40,7 +40,7 @@ export default { :is-event-processed="editTimelineEventActive" :previous-occurred-at="event.occurredAt" :previous-note="event.note" - show-delete + is-editing @save-event="saveEvent" @cancel="$emit('hide-edit')" @delete="$emit('delete')" diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue index f1a3aebc990..6648e20865d 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue @@ -1,7 +1,9 @@ <script> -import { GlDatepicker, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui'; +import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlListbox } from '@gitlab/ui'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; -import { MAX_TEXT_LENGTH, timelineFormI18n } from './constants'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { __, sprintf } from '~/locale'; +import { MAX_TEXT_LENGTH, TIMELINE_EVENT_TAGS, timelineFormI18n } from './constants'; import { getUtcShiftedDate } from './utils'; export default { @@ -23,7 +25,9 @@ export default { GlFormInput, GlFormGroup, GlButton, + GlListbox, }, + mixins: [glFeatureFlagsMixin()], i18n: timelineFormI18n, MAX_TEXT_LENGTH, props: { @@ -32,7 +36,7 @@ export default { required: false, default: false, }, - showDelete: { + isEditing: { type: Boolean, required: false, default: false, @@ -51,6 +55,16 @@ export default { required: false, default: '', }, + previousTags: { + type: Array, + required: false, + default: () => [], + }, + tags: { + type: Array, + required: false, + default: () => TIMELINE_EVENT_TAGS, + }, }, data() { // if occurredAt is null, returns "now" in UTC @@ -58,10 +72,12 @@ export default { return { timelineText: this.previousNote, + timelineTextIsDirty: this.isEditing, placeholderDate, hourPickerInput: placeholderDate.getHours(), minutePickerInput: placeholderDate.getMinutes(), datePickerInput: placeholderDate, + selectedTags: [...this.previousTags], }; }, computed: { @@ -85,6 +101,20 @@ export default { timelineTextCount() { return this.timelineText.length; }, + dropdownText() { + if (!this.selectedTags.length) { + return timelineFormI18n.selectTags; + } + + const dropdownText = + this.selectedTags.length === 1 + ? this.selectedTags[0] + : sprintf(__('%{numberOfSelectedTags} tags'), { + numberOfSelectedTags: this.selectedTags.length, + }); + + return dropdownText; + }, }, mounted() { this.focusDate(); @@ -96,14 +126,35 @@ export default { this.hourPickerInput = newPlaceholderDate.getHours(); this.minutePickerInput = newPlaceholderDate.getMinutes(); this.timelineText = ''; + this.selectedTags = []; }, focusDate() { this.$refs.datepicker.$el.querySelector('input')?.focus(); }, + setTimelineTextDirty() { + this.timelineTextIsDirty = true; + }, + onTagsChange(tagValue) { + this.selectedTags = [...tagValue]; + + if (!this.timelineTextIsDirty) { + this.timelineText = this.generateTimelineTextFromTags(this.selectedTags); + } + }, + generateTimelineTextFromTags(tags) { + if (!tags.length) { + return ''; + } + + const tagsMessage = tags.map((tag) => tag.toLocaleLowerCase()).join(', '); + + return `${timelineFormI18n.areaDefaultMessage} ${tagsMessage}`; + }, handleSave(addAnotherEvent) { const event = { note: this.timelineText, occurredAt: this.occurredAtString, + timelineEventTags: this.selectedTags, }; this.$emit('save-event', event, addAnotherEvent); }, @@ -146,6 +197,16 @@ export default { <p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p> </div> </div> + <gl-form-group v-if="glFeatures.incidentEventTags" :label="$options.i18n.tagsLabel"> + <gl-listbox + :selected="selectedTags" + :toggle-text="dropdownText" + :items="tags" + :is-check-centered="true" + :multiple="true" + @select="onTagsChange" + /> + </gl-form-group> <div class="common-note-form"> <gl-form-group class="gl-mb-3" :label="$options.i18n.areaLabel"> <markdown-field @@ -169,6 +230,7 @@ export default { aria-describedby="timeline-form-hint" :placeholder="$options.i18n.areaPlaceholder" :maxlength="$options.MAX_TEXT_LENGTH" + @input="setTimelineTextDirty" > </textarea> <div id="timeline-form-hint" class="gl-sr-only">{{ $options.i18n.hint }}</div> @@ -214,7 +276,7 @@ export default { {{ $options.i18n.cancel }} </gl-button> <gl-button - v-if="showDelete" + v-if="isEditing" class="gl-ml-auto btn-danger" :disabled="isEventProcessed" @click="$emit('delete')" diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js index 3cb5007ab0d..21d877c5fe6 100644 --- a/app/assets/javascripts/issues/show/index.js +++ b/app/assets/javascripts/issues/show/index.js @@ -83,7 +83,7 @@ export function initIssueApp(issueData, store) { return undefined; } - const { fullPath } = el.dataset; + const { fullPath, registerPath, signInPath } = el.dataset; scrollToTargetOnResize(); @@ -99,6 +99,8 @@ export function initIssueApp(issueData, store) { provide: { canCreateIncident, fullPath, + registerPath, + signInPath, hasIssueWeightsFeature, }, computed: { @@ -150,6 +152,8 @@ export function initHeaderActions(store, type = '') { projectPath: el.dataset.projectPath, projectId: el.dataset.projectId, reportAbusePath: el.dataset.reportAbusePath, + reportedUserId: el.dataset.reportedUserId, + reportedFromUrl: el.dataset.reportedFromUrl, submitAsSpamPath: el.dataset.submitAsSpamPath, }, render: (createElement) => createElement(HeaderActions), 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 9b36642feb7..dd9afb01590 100644 --- a/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue +++ b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue @@ -1,11 +1,6 @@ <script> -import { - GlDropdown, - GlSearchBoxByType, - GlLoadingIcon, - GlDropdownItem, - GlAvatarLabeled, -} from '@gitlab/ui'; +import { GlAvatarLabeled, GlCollapsibleListbox } from '@gitlab/ui'; +import { debounce } from 'lodash'; import { __ } from '~/locale'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; import { PROJECTS_PER_PAGE } from '../constants'; @@ -17,11 +12,8 @@ export default { endCursor: '', }, components: { - GlDropdown, - GlDropdownItem, - GlSearchBoxByType, - GlLoadingIcon, GlAvatarLabeled, + GlCollapsibleListbox, }, props: { selectedProject: { @@ -34,6 +26,7 @@ export default { return { initialProjectsLoading: true, projectSearchQuery: '', + selectedProjectId: this.selectedProject?.id, }; }, apollo: { @@ -66,17 +59,27 @@ export default { projectDropdownText() { return this.selectedProject?.nameWithNamespace || this.$options.i18n.selectProjectText; }, + projectList() { + return (this.projects || []).map((project) => ({ + ...project, + text: project.nameWithNamespace, + value: String(project.id), + })); + }, }, methods: { - onProjectSelect(project) { - this.$emit('change', project); + findProjectById(id) { + return this.projects.find((project) => id === project.id); + }, + onProjectSelect(projectId) { + this.$emit('change', this.findProjectById(projectId)); }, onError({ message } = {}) { this.$emit('error', { message }); }, - isProjectSelected(project) { - return project.id === this.selectedProject?.id; - }, + onSearch: debounce(function debouncedSearch(query) { + this.projectSearchQuery = query; + }, 250), }, i18n: { selectProjectText: __('Select a project'), @@ -86,37 +89,29 @@ export default { </script> <template> - <gl-dropdown - :text="projectDropdownText" - :loading="initialProjectsLoading" - menu-class="gl-w-auto!" + <gl-collapsible-listbox + v-model="selectedProjectId" + data-testid="project-select" + :items="projectList" + :toggle-text="projectDropdownText" :header-text="$options.i18n.selectProjectText" + :loading="initialProjectsLoading" + :searchable="true" + :searching="projectsLoading" + @search="onSearch" + @select="onProjectSelect" > - <template #header> - <gl-search-box-by-type v-model.trim="projectSearchQuery" :debounce="250" /> - </template> - - <gl-loading-icon v-show="projectsLoading" /> - <template v-if="!projectsLoading"> - <gl-dropdown-item - v-for="project in projects" - :key="project.id" - is-check-item - is-check-centered - :is-checked="isProjectSelected(project)" - :data-testid="`test-project-${project.id}`" - @click="onProjectSelect(project)" - > - <gl-avatar-labeled - class="gl-text-truncate" - :shape="$options.AVATAR_SHAPE_OPTION_RECT" - :size="32" - :src="project.avatarUrl" - :label="project.name" - :entity-name="project.name" - :sub-label="project.nameWithNamespace" - /> - </gl-dropdown-item> + <template #list-item="{ item: project }"> + <gl-avatar-labeled + v-if="project" + class="gl-text-truncate" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" + :size="32" + :src="project.avatarUrl" + :label="project.name" + :entity-name="project.name" + :sub-label="project.nameWithNamespace" + /> </template> - </gl-dropdown> + </gl-collapsible-listbox> </template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue index 22a6c0751f4..44575455a34 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue @@ -10,7 +10,6 @@ import { SET_ALERT } from '../store/mutation_types'; import SignInPage from '../pages/sign_in/sign_in_page.vue'; import SubscriptionsPage from '../pages/subscriptions_page.vue'; import UserLink from './user_link.vue'; -import CompatibilityAlert from './compatibility_alert.vue'; import BrowserSupportAlert from './browser_support_alert.vue'; export default { @@ -19,11 +18,10 @@ export default { GlAlert, GlLink, GlSprintf, - UserLink, - CompatibilityAlert, BrowserSupportAlert, SignInPage, SubscriptionsPage, + UserLink, }, mixins: [glFeatureFlagMixin()], inject: { @@ -123,8 +121,6 @@ export default { <main class="jira-connect-app gl-px-5 gl-pt-7 gl-mx-auto"> <browser-support-alert v-if="!isBrowserSupported" class="gl-mb-7" /> <div v-else data-testid="jira-connect-app"> - <compatibility-alert class="gl-mb-7" /> - <gl-alert v-if="shouldShowAlert" :variant="alert.variant" diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue b/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue deleted file mode 100644 index 9b50681515e..00000000000 --- a/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue +++ /dev/null @@ -1,73 +0,0 @@ -<script> -import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import { helpPagePath } from '~/helpers/help_page_helper'; -import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; - -const COMPATIBILITY_ALERT_STATE_KEY = 'compatibility_alert_dismissed'; - -export default { - name: 'CompatibilityAlert', - components: { - GlAlert, - GlSprintf, - GlLink, - LocalStorageSync, - }, - mixins: [glFeatureFlagMixin()], - data() { - return { - alertDismissed: false, - }; - }, - computed: { - shouldShowAlert() { - return !this.alertDismissed; - }, - isOauthSelfManagedEnabled() { - return this.glFeatures.jiraConnectOauth && this.glFeatures.jiraConnectOauthSelfManaged; - }, - alertBody() { - return this.isOauthSelfManagedEnabled - ? this.$options.i18n.body - : this.$options.i18n.bodyDotCom; - }, - }, - methods: { - dismissAlert() { - this.alertDismissed = true; - }, - }, - i18n: { - title: s__('Integrations|Known limitations'), - body: s__( - 'Integrations|Adding a namespace only works in browsers that allow cross-site cookies. %{linkStart}Learn more%{linkEnd}.', - ), - bodyDotCom: s__( - 'Integrations|This integration only works with GitLab.com. Adding a namespace only works in browsers that allow cross-site cookies. %{linkStart}Learn more%{linkEnd}.', - ), - }, - DOCS_LINK_URL: helpPagePath('integration/jira/connect-app'), - COMPATIBILITY_ALERT_STATE_KEY, -}; -</script> -<template> - <local-storage-sync - v-model="alertDismissed" - :storage-key="$options.COMPATIBILITY_ALERT_STATE_KEY" - > - <gl-alert - v-if="shouldShowAlert" - variant="info" - :title="$options.i18n.title" - @dismiss="dismissAlert" - > - <gl-sprintf :message="alertBody"> - <template #link="{ content }"> - <gl-link :href="$options.DOCS_LINK_URL" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </gl-alert> - </local-storage-sync> -</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue index 4cf3a1a0279..65c69bcfa82 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue @@ -4,6 +4,7 @@ import { GlButton } from '@gitlab/ui'; import { sprintf } from '~/locale'; import { + GITLAB_COM_BASE_PATH, I18N_DEFAULT_SIGN_IN_BUTTON_TEXT, I18N_CUSTOM_SIGN_IN_BUTTON_TEXT, I18N_OAUTH_APPLICATION_ID_ERROR_MESSAGE, @@ -40,8 +41,11 @@ export default { }; }, computed: { + isGitlabCom() { + return this.gitlabBasePath === GITLAB_COM_BASE_PATH; + }, buttonText() { - if (!this.gitlabBasePath) { + if (this.isGitlabCom) { return I18N_DEFAULT_SIGN_IN_BUTTON_TEXT; } @@ -71,9 +75,9 @@ export default { this.codeVerifier = createCodeVerifier(); const codeChallenge = await createCodeChallenge(this.codeVerifier); try { - this.clientId = this.gitlabBasePath - ? await this.fetchOauthClientId() - : this.oauthMetadata?.oauth_token_payload?.client_id; + this.clientId = this.isGitlabCom + ? this.oauthMetadata?.oauth_token_payload?.client_id + : await this.fetchOauthClientId(); } catch { throw new Error(I18N_OAUTH_APPLICATION_ID_ERROR_MESSAGE); } @@ -92,7 +96,7 @@ export default { ); // Rebase URL on the specified GitLab base path (if specified). - if (this.gitlabBasePath) { + if (!this.isGitlabCom) { const gitlabBasePathURL = new URL(this.gitlabBasePath); oauthAuthorizeURLWithChallenge.hostname = gitlabBasePathURL.hostname; oauthAuthorizeURLWithChallenge.pathname = `${ @@ -118,7 +122,7 @@ export default { this.setAlert({ linkUrl: OAUTH_SELF_MANAGED_DOC_LINK, title: I18N_OAUTH_FAILED_TITLE, - message: this.gitlabBasePath ? I18N_OAUTH_FAILED_MESSAGE : '', + message: this.isGitlabCom ? '' : I18N_OAUTH_FAILED_MESSAGE, variant: 'danger', }); } diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue index 91b66c87694..782e8a625a9 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue @@ -1,6 +1,7 @@ <script> import { s__ } from '~/locale'; +import { GITLAB_COM_BASE_PATH } from '~/jira_connect/subscriptions/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import SubscriptionsList from '../../components/subscriptions_list.vue'; @@ -28,6 +29,7 @@ export default { signInButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'), signInText: s__('JiraService|Sign in to GitLab.com to get started.'), }, + GITLAB_COM_BASE_PATH, methods: { onSignInError() { this.$emit('error'); @@ -43,6 +45,7 @@ export default { <div class="gl-display-flex gl-justify-content-end gl-mb-3"> <sign-in-oauth-button v-if="useSignInOauthButton" + :gitlab-base-path="$options.GITLAB_COM_BASE_PATH" @sign-in="$emit('sign-in-oauth', $event)" @error="onSignInError" > @@ -59,6 +62,7 @@ export default { <p class="gl-mb-7">{{ $options.i18n.signInText }}</p> <sign-in-oauth-button v-if="useSignInOauthButton" + :gitlab-base-path="$options.GITLAB_COM_BASE_PATH" @sign-in="$emit('sign-in-oauth', $event)" @error="onSignInError" /> diff --git a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue index d7bbd6daed2..734d3ca0d49 100644 --- a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue +++ b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue @@ -68,6 +68,7 @@ export default { required: true, }, }, + clearBtnSharedClasses: ['gl-flex-grow-0 gl-flex-basis-0'], inputTypes: { key: 'key', value: 'value', @@ -229,16 +230,23 @@ export default { v-gl-tooltip :aria-label="$options.i18n.clearInputs" :title="$options.i18n.clearInputs" - class="gl-flex-grow-0 gl-flex-basis-0" + :class="$options.clearBtnSharedClasses" category="tertiary" variant="danger" icon="clear" data-testid="delete-variable-btn" @click="deleteVariable(variable.id)" /> - - <!-- delete variable button placeholder to not break flex layout --> - <div v-else class="gl-w-7 gl-mr-3" data-testid="delete-variable-btn-placeholder"></div> + <!-- Placeholder button to keep the layout fixed --> + <gl-button + v-else + class="gl-opacity-0 gl-pointer-events-none" + :class="$options.clearBtnSharedClasses" + data-testid="delete-variable-btn-placeholder" + category="tertiary" + variant="danger" + icon="clear" + /> </div> <div class="gl-text-center gl-mt-5"> diff --git a/app/assets/javascripts/jobs/components/table/jobs_table.vue b/app/assets/javascripts/jobs/components/table/jobs_table.vue index d8c5c292f52..9ee4439b618 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table.vue @@ -1,7 +1,7 @@ <script> import { GlTable } from '@gitlab/ui'; import { s__ } from '~/locale'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import ActionsCell from './cells/actions_cell.vue'; import DurationCell from './cells/duration_cell.vue'; import JobCell from './cells/job_cell.vue'; @@ -14,7 +14,7 @@ export default { }, components: { ActionsCell, - CiBadge, + CiBadgeLink, DurationCell, GlTable, JobCell, @@ -55,7 +55,7 @@ export default { </template> <template #cell(status)="{ item }"> - <ci-badge :status="item.detailedStatus" /> + <ci-badge-link :status="item.detailedStatus" /> </template> <template #cell(job)="{ item }"> diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js index 272181f830c..a81edb240ad 100644 --- a/app/assets/javascripts/jobs/store/actions.js +++ b/app/assets/javascripts/jobs/store/actions.js @@ -2,7 +2,7 @@ import Visibility from 'visibilityjs'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { setFaviconOverlay, resetFavicon } from '~/lib/utils/favicon'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_FORBIDDEN } from '~/lib/utils/http_status'; import Poll from '~/lib/utils/poll'; import { canScroll, @@ -178,7 +178,7 @@ export const fetchJobLog = ({ dispatch, state }) => } }) .catch((e) => { - if (e.response.status === httpStatusCodes.FORBIDDEN) { + if (e.response.status === HTTP_STATUS_FORBIDDEN) { dispatch('receiveJobLogUnauthorizedError'); } else { reportToSentry('job_actions', e); diff --git a/app/assets/javascripts/language_switcher/components/app.vue b/app/assets/javascripts/language_switcher/components/app.vue index 71babe6c614..4d3fe22e247 100644 --- a/app/assets/javascripts/language_switcher/components/app.vue +++ b/app/assets/javascripts/language_switcher/components/app.vue @@ -1,11 +1,17 @@ <script> -import { GlCollapsibleListbox } from '@gitlab/ui'; +import { GlCollapsibleListbox, GlLink } from '@gitlab/ui'; +import { __ } from '~/locale'; import { setCookie } from '~/lib/utils/common_utils'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { PREFERRED_LANGUAGE_COOKIE_KEY } from '../constants'; +const HELP_TRANSLATE_MSG = __('Help translate to your language'); +const HELP_TRANSLATE_HREF = helpPagePath('/development/i18n/translation.md'); + export default { components: { GlCollapsibleListbox, + GlLink, }, inject: { locales: { @@ -25,7 +31,12 @@ export default { setCookie(PREFERRED_LANGUAGE_COOKIE_KEY, code); window.location.reload(); }, + itemTestSelector(locale) { + return `language_switcher_lang_${locale}`; + }, }, + HELP_TRANSLATE_MSG, + HELP_TRANSLATE_HREF, }; </script> <template> @@ -41,9 +52,20 @@ export default { @select="onLanguageSelected" > <template #list-item="{ item: locale }"> - <span :data-testid="`language_switcher_lang_${locale.value}`"> + <span + :data-testid="itemTestSelector(locale.value)" + :data-qa-selector="itemTestSelector(locale.value)" + > {{ locale.text }} </span> </template> + <template #footer> + <div + class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-100 gl-display-flex gl-justify-content-center gl-p-3" + data-testid="footer" + > + <gl-link :href="$options.HELP_TRANSLATE_HREF">{{ $options.HELP_TRANSLATE_MSG }}</gl-link> + </div> + </template> </gl-collapsible-listbox> </template> diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js index ab83f1ecc14..90c1b31286a 100644 --- a/app/assets/javascripts/layout_nav.js +++ b/app/assets/javascripts/layout_nav.js @@ -12,8 +12,45 @@ function hideEndFade($scrollingTabs) { }); } +export function initScrollingTabs() { + const $scrollingTabs = $('.scrolling-tabs').not('.is-initialized'); + $scrollingTabs.addClass('is-initialized'); + + $(window) + .on('resize.nav', () => { + hideEndFade($scrollingTabs); + }) + .trigger('resize.nav'); + + $scrollingTabs.on('scroll', function tabsScrollEvent() { + const $this = $(this); + const currentPosition = $this.scrollLeft(); + const maxPosition = $this.prop('scrollWidth') - $this.outerWidth(); + + $this.siblings('.fade-left').toggleClass('scrolling', currentPosition > 0); + $this.siblings('.fade-right').toggleClass('scrolling', currentPosition < maxPosition - 1); + }); + + $scrollingTabs.each(function scrollTabsEachLoop() { + const $this = $(this); + const scrollingTabWidth = $this.width(); + const $active = $this.find('.active'); + const activeWidth = $active.width(); + + if ($active.length) { + const offset = $active.offset().left + activeWidth; + + if (offset > scrollingTabWidth - 30) { + const scrollLeft = offset - scrollingTabWidth / 2 - activeWidth / 2; + + $this.scrollLeft(scrollLeft); + } + } + }); +} + function initDeferred() { - $(document).trigger('init.scrolling-tabs'); + initScrollingTabs(); const appEl = document.getElementById('whats-new-app'); if (!appEl) return; @@ -34,43 +71,5 @@ export default function initLayoutNav() { initFlyOutNav(); - // We need to init it on DomContentLoaded as others could also call it - $(document).on('init.scrolling-tabs', () => { - const $scrollingTabs = $('.scrolling-tabs').not('.is-initialized'); - $scrollingTabs.addClass('is-initialized'); - - $(window) - .on('resize.nav', () => { - hideEndFade($scrollingTabs); - }) - .trigger('resize.nav'); - - $scrollingTabs.on('scroll', function tabsScrollEvent() { - const $this = $(this); - const currentPosition = $this.scrollLeft(); - const maxPosition = $this.prop('scrollWidth') - $this.outerWidth(); - - $this.siblings('.fade-left').toggleClass('scrolling', currentPosition > 0); - $this.siblings('.fade-right').toggleClass('scrolling', currentPosition < maxPosition - 1); - }); - - $scrollingTabs.each(function scrollTabsEachLoop() { - const $this = $(this); - const scrollingTabWidth = $this.width(); - const $active = $this.find('.active'); - const activeWidth = $active.width(); - - if ($active.length) { - const offset = $active.offset().left + activeWidth; - - if (offset > scrollingTabWidth - 30) { - const scrollLeft = offset - scrollingTabWidth / 2 - activeWidth / 2; - - $this.scrollLeft(scrollLeft); - } - } - }); - }); - requestIdleCallback(initDeferred); } diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 4ce63d518a6..241488c8039 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -142,7 +142,7 @@ export const getOuterHeight = (selector) => { const element = document.querySelector(selector); if (!element) { - return undefined; + return 0; } return element.offsetHeight; @@ -154,6 +154,11 @@ export const contentTop = () => { () => getOuterHeight('#js-peek'), () => getOuterHeight('.navbar-gitlab'), ({ desktop }) => { + const mrStickyHeader = document.querySelector('.merge-request-sticky-header'); + if (mrStickyHeader) { + return mrStickyHeader.offsetHeight; + } + const container = document.querySelector('.discussions-counter'); let size = 0; @@ -161,11 +166,12 @@ export const contentTop = () => { size = container.offsetHeight; } + size += getOuterHeight('.merge-request-tabs'); + size += getOuterHeight('.issue-sticky-header.gl-fixed'); + return size; }, - () => getOuterHeight('.merge-request-sticky-header, .merge-request-tabs'), () => getOuterHeight('.js-diff-files-changed'), - () => getOuterHeight('.issue-sticky-header.gl-fixed'), ({ desktop }) => { const diffsTabIsActive = window.mrTabs?.currentAction === 'diffs'; let size; diff --git a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js index 4e0a59d0a38..9eb812b8694 100644 --- a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js @@ -531,7 +531,7 @@ export const getOverlappingDaysInPeriods = (givenPeriodLeft = {}, givenPeriodRig /** * Mimics the behaviour of the rails distance_of_time_in_words function - * https://api.rubyonrails.org/v6.0.1/classes/ActionView/Helpers/DateHelper.html#method-i-distance_of_time_in_words + * https://api.rubyonrails.org/classes/ActionView/Helpers/DateHelper.html#method-i-distance_of_time_in_words * 0 < -> 29 secs => less than a minute * 30 secs < -> 1 min, 29 secs => 1 minute * 1 min, 30 secs < -> 44 mins, 29 secs => [2..44] minutes diff --git a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js index 737c18d1bce..04a82836f69 100644 --- a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js @@ -414,21 +414,21 @@ export const durationTimeFormatted = (duration) => { * * @param {Number} offset UTC offset in seconds as a integer * - * @return {String} the + or - offset in hours, e.g. `- 10`, `0`, `+ 4` + * @return {String} the + or - offset in hours, e.g. `-10`, ` 0`, `+4` */ export const formatUtcOffset = (offset) => { const parsed = parseInt(offset, 10); if (Number.isNaN(parsed) || parsed === 0) { - return `0`; + return ` 0`; } const prefix = offset > 0 ? '+' : '-'; - return `${prefix} ${Math.abs(offset / 3600)}`; + return `${prefix}${Math.abs(offset / 3600)}`; }; /** * Returns formatted timezone * * @param {Object} timezone item with offset and name - * @returns {String} the UTC timezone with the offset, e.g. `[UTC + 2] Berlin` + * @returns {String} the UTC timezone with the offset, e.g. `[UTC+2] Berlin, [UTC 0] London` */ -export const formatTimezone = ({ offset, name }) => `[UTC ${formatUtcOffset(offset)}] ${name}`; +export const formatTimezone = ({ offset, name }) => `[UTC${formatUtcOffset(offset)}] ${name}`; diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js index ec0d8d433a5..678ebc35565 100644 --- a/app/assets/javascripts/lib/utils/http_status.js +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -1,4 +1,5 @@ export const HTTP_STATUS_ABORTED = 0; +export const HTTP_STATUS_OK = 200; export const HTTP_STATUS_CREATED = 201; export const HTTP_STATUS_ACCEPTED = 202; export const HTTP_STATUS_NON_AUTHORITATIVE_INFORMATION = 203; @@ -14,21 +15,15 @@ export const HTTP_STATUS_GONE = 410; export const HTTP_STATUS_PAYLOAD_TOO_LARGE = 413; export const HTTP_STATUS_UNPROCESSABLE_ENTITY = 422; export const HTTP_STATUS_TOO_MANY_REQUESTS = 429; - -// TODO move the rest of the status codes to primitive constants -// https://docs.gitlab.com/ee/development/fe_guide/style/javascript.html#export-constants-as-primitives -const httpStatusCodes = { - OK: 200, - BAD_REQUEST: 400, - UNAUTHORIZED: 401, - FORBIDDEN: 403, - NOT_FOUND: 404, - INTERNAL_SERVER_ERROR: 500, - SERVICE_UNAVAILABLE: 503, -}; +export const HTTP_STATUS_BAD_REQUEST = 400; +export const HTTP_STATUS_UNAUTHORIZED = 401; +export const HTTP_STATUS_FORBIDDEN = 403; +export const HTTP_STATUS_NOT_FOUND = 404; +export const HTTP_STATUS_INTERNAL_SERVER_ERROR = 500; +export const HTTP_STATUS_SERVICE_UNAVAILABLE = 503; export const successCodes = [ - httpStatusCodes.OK, + HTTP_STATUS_OK, HTTP_STATUS_CREATED, HTTP_STATUS_ACCEPTED, HTTP_STATUS_NON_AUTHORITATIVE_INFORMATION, @@ -39,5 +34,3 @@ export const successCodes = [ HTTP_STATUS_ALREADY_REPORTED, HTTP_STATUS_IM_USED, ]; - -export default httpStatusCodes; diff --git a/app/assets/javascripts/lib/utils/poll_until_complete.js b/app/assets/javascripts/lib/utils/poll_until_complete.js index 3545db3a227..dbe54dceb52 100644 --- a/app/assets/javascripts/lib/utils/poll_until_complete.js +++ b/app/assets/javascripts/lib/utils/poll_until_complete.js @@ -1,5 +1,5 @@ import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes from './http_status'; +import { HTTP_STATUS_OK } from './http_status'; import Poll from './poll'; /** @@ -30,7 +30,7 @@ export default (url, config = {}) => data: { url, config }, method: 'axiosGet', successCallback: (response) => { - if (response.status === httpStatusCodes.OK) { + if (response.status === HTTP_STATUS_OK) { resolve(response); eTagPoll.stop(); } diff --git a/app/assets/javascripts/locale/ensure_single_line.js b/app/assets/javascripts/locale/ensure_single_line.cjs index c2c63777001..c2c63777001 100644 --- a/app/assets/javascripts/locale/ensure_single_line.js +++ b/app/assets/javascripts/locale/ensure_single_line.cjs diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js index ad01da2eb17..c1afabf1e35 100644 --- a/app/assets/javascripts/locale/index.js +++ b/app/assets/javascripts/locale/index.js @@ -1,5 +1,5 @@ import Jed from 'jed'; -import ensureSingleLine from './ensure_single_line'; +import ensureSingleLine from './ensure_single_line.cjs'; import sprintf from './sprintf'; const GITLAB_FALLBACK_LANGUAGE = 'en'; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index df3b55ed2ad..fd5c4abe729 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -30,6 +30,7 @@ import initLogoAnimation from './logo'; import initBreadcrumbs from './breadcrumb'; import initPersistentUserCallouts from './persistent_user_callouts'; import { initUserTracking, initDefaultTrackers } from './tracking'; +import { initSidebarTracking } from './pages/shared/nav/sidebar_tracking'; import initServicePingConsent from './service_ping_consent'; import GlFieldErrors from './gl_field_errors'; import initUserPopovers from './user_popovers'; @@ -99,10 +100,19 @@ function deferredInitialisation() { initBroadcastNotifications(); initPersistentUserCallouts(); initDefaultTrackers(); + initSidebarTracking(); initFeatureHighlight(); initCopyCodeButton(); initGitlabVersionCheck(); + // Init super sidebar + if (gon.use_new_navigation) { + // eslint-disable-next-line promise/catch-or-return + import('./super_sidebar/super_sidebar_bundle').then(({ initSuperSidebar }) => { + initSuperSidebar(); + }); + } + addSelectOnFocusBehaviour('.js-select-on-focus'); const glTooltipDelay = localStorage.getItem('gl-tooltip-delay'); diff --git a/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue index f4893721b9e..164fed308ff 100644 --- a/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue @@ -49,8 +49,6 @@ export default { :message="message" :title="s__('Member|Deny access')" :is-access-request="true" - icon="close" - button-category="primary" /> </div> </action-button-group> diff --git a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue index 112f722c632..90034f46e7c 100644 --- a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue @@ -40,7 +40,6 @@ export default { :title="$options.title" :aria-label="$options.title" icon="check" - variant="confirm" type="submit" /> </gl-form> diff --git a/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue index ab9abfd38c6..91062c222f4 100644 --- a/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue @@ -41,8 +41,6 @@ export default { <remove-member-button :member-id="member.id" :message="message" - icon="remove" - button-category="primary" :title="s__('Member|Revoke invite')" is-invite /> diff --git a/app/assets/javascripts/members/components/action_buttons/leave_button.vue b/app/assets/javascripts/members/components/action_buttons/leave_button.vue deleted file mode 100644 index f600a207b8d..00000000000 --- a/app/assets/javascripts/members/components/action_buttons/leave_button.vue +++ /dev/null @@ -1,40 +0,0 @@ -<script> -import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; -import { LEAVE_MODAL_ID } from '../../constants'; -import LeaveModal from '../modals/leave_modal.vue'; - -export default { - name: 'LeaveButton', - title: __('Leave'), - modalId: LEAVE_MODAL_ID, - components: { - GlButton, - LeaveModal, - }, - directives: { - GlModal: GlModalDirective, - GlTooltip: GlTooltipDirective, - }, - props: { - member: { - type: Object, - required: true, - }, - }, -}; -</script> - -<template> - <div> - <gl-button - v-gl-tooltip.hover - v-gl-modal="$options.modalId" - :title="$options.title" - :aria-label="$options.title" - icon="leave" - variant="danger" - /> - <leave-modal :member="member" /> - </div> -</template> diff --git a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue index fef7940eaa2..24500fbe44d 100644 --- a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue @@ -32,7 +32,6 @@ export default { <template> <gl-button v-gl-tooltip.hover - variant="danger" :title="$options.i18n.buttonTitle" :aria-label="$options.i18n.buttonTitle" icon="remove" 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 27c67e84675..4b3bb89da55 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 @@ -14,34 +14,13 @@ export default { type: Number, required: true, }, - memberType: { - type: String, - required: false, - default: null, - }, message: { type: String, required: true, }, title: { type: String, - required: false, - default: null, - }, - icon: { - type: String, - required: false, - default: undefined, - }, - buttonText: { - type: String, - required: false, - default: '', - }, - buttonCategory: { - type: String, - required: false, - default: 'secondary', + required: true, }, isAccessRequest: { type: Boolean, @@ -70,7 +49,6 @@ export default { isAccessRequest: this.isAccessRequest, isInvite: this.isInvite, memberPath: this.memberPath.replace(':id', this.memberId), - memberType: this.memberType, message: this.message, userDeletionObstacles: this.userDeletionObstacles, }; @@ -89,13 +67,10 @@ export default { <template> <gl-button v-gl-tooltip - variant="danger" - :category="buttonCategory" :title="title" :aria-label="title" - :icon="icon" + icon="remove" data-qa-selector="delete_member_button" @click="showRemoveMemberModal(modalData)" - ><template v-if="buttonText">{{ buttonText }}</template></gl-button - > + /> </template> diff --git a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue deleted file mode 100644 index 122e0a142a9..00000000000 --- a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue +++ /dev/null @@ -1,95 +0,0 @@ -<script> -import { __, s__, sprintf } from '~/locale'; -import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; -import ActionButtonGroup from './action_button_group.vue'; -import LeaveButton from './leave_button.vue'; -import RemoveMemberButton from './remove_member_button.vue'; - -export default { - name: 'UserActionButtons', - components: { - ActionButtonGroup, - RemoveMemberButton, - LeaveButton, - LdapOverrideButton: () => - import('ee_component/members/components/ldap/ldap_override_button.vue'), - }, - props: { - member: { - type: Object, - required: true, - }, - isCurrentUser: { - type: Boolean, - required: true, - }, - isInvitedUser: { - type: Boolean, - required: true, - }, - permissions: { - type: Object, - required: true, - }, - }, - computed: { - message() { - const { user, source } = this.member; - - if (user) { - return sprintf( - s__('Members|Are you sure you want to remove %{usersName} from "%{source}"?'), - { - usersName: user.name, - source: source.fullName, - }, - false, - ); - } - - return sprintf( - s__('Members|Are you sure you want to remove this orphaned member from "%{source}"?'), - { - source: source.fullName, - }, - ); - }, - userDeletionObstaclesUserData() { - return { - name: this.member.user?.name, - obstacles: parseUserDeletionObstacles(this.member.user), - }; - }, - removeMemberButtonText() { - return this.isInvitedUser ? null : __('Remove member'); - }, - removeMemberButtonIcon() { - return this.isInvitedUser ? 'remove' : ''; - }, - removeMemberButtonCategory() { - return this.isInvitedUser ? 'primary' : 'secondary'; - }, - }, -}; -</script> - -<template> - <action-button-group> - <div v-if="permissions.canRemove" class="gl-px-1"> - <leave-button v-if="isCurrentUser" :member="member" /> - <remove-member-button - v-else - :member-id="member.id" - :member-type="member.type" - :user-deletion-obstacles="userDeletionObstaclesUserData" - :message="message" - :icon="removeMemberButtonIcon" - :button-text="removeMemberButtonText" - :button-category="removeMemberButtonCategory" - /> - </div> - <div v-else-if="permissions.canOverride && !member.isOverridden" class="gl-px-1"> - <ldap-override-button :member="member" /> - </div> - </action-button-group> -</template> diff --git a/app/assets/javascripts/members/components/action_dropdowns/constants.js b/app/assets/javascripts/members/components/action_dropdowns/constants.js new file mode 100644 index 00000000000..8ccfc57dc28 --- /dev/null +++ b/app/assets/javascripts/members/components/action_dropdowns/constants.js @@ -0,0 +1,22 @@ +import { __, s__ } from '~/locale'; + +export const I18N = { + actions: __('More actions'), + disableTwoFactor: s__('Members|Disable two-factor authentication'), + editPermissions: s__('Members|Edit permissions'), + leaveGroup: __('Leave group'), + removeMember: __('Remove member'), + confirmDisableTwoFactor: s__( + 'Members|Are you sure you want to disable the two-factor authentication for %{userName}?', + ), + confirmNormalUserRemoval: s__( + 'Members|Are you sure you want to remove %{userName} from "%{group}"?', + ), + confirmOrphanedUserRemoval: s__( + 'Members|Are you sure you want to remove this orphaned member from "%{group}"?', + ), + personalProjectOwnerCannotBeRemoved: s__("Members|A personal project's owner cannot be removed."), + lastGroupOwnerCannotBeRemoved: s__( + 'Members|A group must have at least one owner. To remove the member, assign a new owner.', + ), +}; diff --git a/app/assets/javascripts/members/components/action_dropdowns/leave_group_dropdown_item.vue b/app/assets/javascripts/members/components/action_dropdowns/leave_group_dropdown_item.vue new file mode 100644 index 00000000000..15606ad567c --- /dev/null +++ b/app/assets/javascripts/members/components/action_dropdowns/leave_group_dropdown_item.vue @@ -0,0 +1,36 @@ +<script> +import { GlDropdownItem, GlModalDirective } from '@gitlab/ui'; +import { LEAVE_MODAL_ID } from '../../constants'; +import LeaveModal from '../modals/leave_modal.vue'; + +export default { + name: 'LeaveGroupDropdownItem', + modalId: LEAVE_MODAL_ID, + components: { + GlDropdownItem, + LeaveModal, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + member: { + type: Object, + required: true, + }, + permissions: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <gl-dropdown-item v-gl-modal="$options.modalId"> + <span class="gl-text-red-500"> + <slot></slot> + </span> + <leave-modal :member="member" :permissions="permissions" /> + </gl-dropdown-item> +</template> diff --git a/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue b/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue new file mode 100644 index 00000000000..f224aaa31f7 --- /dev/null +++ b/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue @@ -0,0 +1,86 @@ +<script> +import { GlDropdownItem } from '@gitlab/ui'; +import { mapActions, mapState } from 'vuex'; + +export default { + name: 'RemoveMemberDropdownItem', + components: { GlDropdownItem }, + inject: ['namespace'], + props: { + memberId: { + type: Number, + required: true, + }, + /** + * `GroupMember` (`app/models/members/group_member.rb`) + * or + * `ProjectMember` (`app/models/members/project_member.rb`). + */ + memberModelType: { + type: String, + required: false, + default: null, + }, + modalMessage: { + type: String, + required: true, + }, + isAccessRequest: { + type: Boolean, + required: false, + default: false, + }, + isInvite: { + type: Boolean, + required: false, + default: false, + }, + userDeletionObstacles: { + type: Object, + required: false, + default: () => ({}), + }, + preventRemoval: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + ...mapState({ + memberPath(state) { + return state[this.namespace].memberPath; + }, + }), + modalData() { + return { + isAccessRequest: this.isAccessRequest, + isInvite: this.isInvite, + memberPath: this.memberPath.replace(':id', this.memberId), + memberModelType: this.memberModelType, + message: this.modalMessage, + userDeletionObstacles: this.userDeletionObstacles, + preventRemoval: this.preventRemoval, + }; + }, + }, + methods: { + ...mapActions({ + showRemoveMemberModal(dispatch, payload) { + return dispatch(`${this.namespace}/showRemoveMemberModal`, payload); + }, + }), + }, +}; +</script> + +<template> + <gl-dropdown-item + data-qa-selector="delete_member_dropdown_item" + @click="showRemoveMemberModal(modalData)" + > + <span class="gl-text-red-500"> + <slot></slot> + </span> + </gl-dropdown-item> +</template> diff --git a/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue b/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue new file mode 100644 index 00000000000..8f5c32956a2 --- /dev/null +++ b/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue @@ -0,0 +1,134 @@ +<script> +import { GlDropdown, GlTooltipDirective } from '@gitlab/ui'; +import { sprintf } from '~/locale'; +import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; +import { + MEMBER_MODEL_TYPE_GROUP_MEMBER, + MEMBER_MODEL_TYPE_PROJECT_MEMBER, +} from '~/members/constants'; +import { I18N } from './constants'; +import LeaveGroupDropdownItem from './leave_group_dropdown_item.vue'; +import RemoveMemberDropdownItem from './remove_member_dropdown_item.vue'; + +export default { + name: 'UserActionDropdown', + i18n: I18N, + components: { + GlDropdown, + DisableTwoFactorDropdownItem: () => + import( + 'ee_component/members/components/action_dropdowns/disable_two_factor_dropdown_item.vue' + ), + LdapOverrideDropdownItem: () => + import('ee_component/members/components/ldap/ldap_override_dropdown_item.vue'), + LeaveGroupDropdownItem, + RemoveMemberDropdownItem, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + member: { + type: Object, + required: true, + }, + isCurrentUser: { + type: Boolean, + required: true, + }, + permissions: { + type: Object, + required: true, + }, + }, + computed: { + modalDisableTwoFactor() { + const userName = this.member.user.username; + return sprintf(this.$options.i18n.confirmDisableTwoFactor, { userName }, false); + }, + modalRemoveUser() { + const { user, source } = this.member; + + if (this.permissions.canRemoveBlockedByLastOwner) { + if (this.member.type === MEMBER_MODEL_TYPE_PROJECT_MEMBER) { + return I18N.personalProjectOwnerCannotBeRemoved; + } + + if (this.member.type === MEMBER_MODEL_TYPE_GROUP_MEMBER) { + return I18N.lastGroupOwnerCannotBeRemoved; + } + } + + if (user) { + return sprintf( + this.$options.i18n.confirmNormalUserRemoval, + { userName: user.name, group: source.fullName }, + false, + ); + } + + return sprintf(this.$options.i18n.confirmOrphanedUserRemoval, { group: source.fullName }); + }, + userDeletionObstaclesUserData() { + return { + name: this.member.user?.name, + obstacles: parseUserDeletionObstacles(this.member.user), + }; + }, + showDropdown() { + return ( + this.permissions.canDisableTwoFactor || this.showLeaveOrRemove || this.showLdapOverride + ); + }, + showLeaveOrRemove() { + return this.permissions.canRemove || this.permissions.canRemoveBlockedByLastOwner; + }, + showLdapOverride() { + return this.permissions.canOverride && !this.member.isOverridden; + }, + }, +}; +</script> + +<template> + <gl-dropdown + v-if="showDropdown" + v-gl-tooltip="$options.i18n.actions" + :text="$options.i18n.actions" + text-sr-only + icon="ellipsis_v" + category="tertiary" + no-caret + right + data-testid="user-action-dropdown" + data-qa-selector="user_action_dropdown" + > + <disable-two-factor-dropdown-item + v-if="permissions.canDisableTwoFactor" + :modal-message="modalDisableTwoFactor" + :user-id="member.user.id" + > + {{ $options.i18n.disableTwoFactor }} + </disable-two-factor-dropdown-item> + + <template v-if="showLeaveOrRemove"> + <leave-group-dropdown-item v-if="isCurrentUser" :member="member" :permissions="permissions">{{ + $options.i18n.leaveGroup + }}</leave-group-dropdown-item> + + <remove-member-dropdown-item + v-else + :member-id="member.id" + :member-model-type="member.type" + :user-deletion-obstacles="userDeletionObstaclesUserData" + :modal-message="modalRemoveUser" + :prevent-removal="permissions.canRemoveBlockedByLastOwner" + >{{ $options.i18n.removeMember }}</remove-member-dropdown-item + > + </template> + + <ldap-override-dropdown-item v-else-if="showLdapOverride" :member="member">{{ + $options.i18n.editPermissions + }}</ldap-override-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/members/components/modals/leave_modal.vue b/app/assets/javascripts/members/components/modals/leave_modal.vue index e39669e17dd..8bc6aca9cc1 100644 --- a/app/assets/javascripts/members/components/modals/leave_modal.vue +++ b/app/assets/javascripts/members/components/modals/leave_modal.vue @@ -5,22 +5,30 @@ import csrf from '~/lib/utils/csrf'; import { __, s__, sprintf } from '~/locale'; import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; -import { LEAVE_MODAL_ID } from '../../constants'; +import { + LEAVE_MODAL_ID, + MEMBER_MODEL_TYPE_GROUP_MEMBER, + MEMBER_MODEL_TYPE_PROJECT_MEMBER, +} from '../../constants'; export default { name: 'LeaveModal', actionCancel: { text: __('Cancel'), }, - actionPrimary: { - text: __('Leave'), - attributes: { - variant: 'danger', - }, - }, csrf, modalId: LEAVE_MODAL_ID, - modalContent: s__('Members|Are you sure you want to leave "%{source}"?'), + i18n: { + title: s__('Members|Leave "%{source}"'), + body: s__('Members|Are you sure you want to leave "%{source}"?'), + preventedTitle: s__('Members|Cannot leave "%{source}"'), + preventedBodyProjectMemberModelType: s__( + 'Members|You cannot remove yourself from a personal project.', + ), + preventedBodyGroupMemberModelType: s__( + 'Members|A group must have at least one owner. To leave this group, assign a new owner.', + ), + }, components: { GlModal, GlForm, GlSprintf, UserDeletionObstaclesList }, directives: { GlTooltip: GlTooltipDirective, @@ -31,6 +39,10 @@ export default { type: Object, required: true, }, + permissions: { + type: Object, + required: true, + }, }, computed: { ...mapState({ @@ -42,7 +54,35 @@ export default { return this.memberPath.replace(/:id$/, 'leave'); }, modalTitle() { - return sprintf(s__('Members|Leave "%{source}"'), { source: this.member.source.fullName }); + return sprintf( + this.permissions.canRemoveBlockedByLastOwner + ? this.$options.i18n.preventedTitle + : this.$options.i18n.title, + { source: this.member.source.fullName }, + ); + }, + preventedModalBody() { + if (this.member.type === MEMBER_MODEL_TYPE_PROJECT_MEMBER) { + return this.$options.i18n.preventedBodyProjectMemberModelType; + } + + if (this.member.type === MEMBER_MODEL_TYPE_GROUP_MEMBER) { + return this.$options.i18n.preventedBodyGroupMemberModelType; + } + + return null; + }, + actionPrimary() { + if (this.permissions.canRemoveBlockedByLastOwner) { + return null; + } + + return { + text: __('Leave'), + attributes: { + variant: 'danger', + }, + }; }, obstacles() { return parseUserDeletionObstacles(this.member.user); @@ -64,13 +104,14 @@ export default { v-bind="$attrs" :modal-id="$options.modalId" :title="modalTitle" - :action-primary="$options.actionPrimary" + :action-primary="actionPrimary" :action-cancel="$options.actionCancel" @primary="handlePrimary" > <gl-form ref="form" :action="leavePath" method="post"> <p> - <gl-sprintf :message="$options.modalContent"> + <template v-if="permissions.canRemoveBlockedByLastOwner">{{ preventedModalBody }}</template> + <gl-sprintf v-else :message="$options.i18n.body"> <template #source>{{ member.source.fullName }}</template> </gl-sprintf> </p> diff --git a/app/assets/javascripts/members/components/modals/remove_member_modal.vue b/app/assets/javascripts/members/components/modals/remove_member_modal.vue index 1bb1f90302c..337379d8b4e 100644 --- a/app/assets/javascripts/members/components/modals/remove_member_modal.vue +++ b/app/assets/javascripts/members/components/modals/remove_member_modal.vue @@ -4,6 +4,7 @@ import { mapActions, mapState } from 'vuex'; import csrf from '~/lib/utils/csrf'; import { s__, __ } from '~/locale'; import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; +import { MEMBER_MODEL_TYPE_GROUP_MEMBER } from '../../constants'; export default { actionCancel: { @@ -27,8 +28,13 @@ export default { memberPath(state) { return state[this.namespace].removeMemberModalData.memberPath; }, - memberType(state) { - return state[this.namespace].removeMemberModalData.memberType; + /** + * `GroupMember` (`app/models/members/group_member.rb`) + * or + * `ProjectMember` (`app/models/members/project_member.rb`). + */ + memberModelType(state) { + return state[this.namespace].removeMemberModalData.memberModelType; }, message(state) { return state[this.namespace].removeMemberModalData.message; @@ -36,12 +42,15 @@ export default { userDeletionObstacles(state) { return state[this.namespace].removeMemberModalData.userDeletionObstacles ?? {}; }, + preventRemoval(state) { + return state[this.namespace].removeMemberModalData.preventRemoval; + }, removeMemberModalVisible(state) { return state[this.namespace].removeMemberModalVisible; }, }), isGroupMember() { - return this.memberType === 'GroupMember'; + return this.memberModelType === MEMBER_MODEL_TYPE_GROUP_MEMBER; }, actionText() { if (this.isAccessRequest) { @@ -53,6 +62,10 @@ export default { return __('Remove member'); }, actionPrimary() { + if (this.preventRemoval) { + return null; + } + return { text: this.actionText, attributes: { @@ -95,21 +108,22 @@ export default { > <form ref="form" :action="memberPath" method="post"> <p>{{ message }}</p> + <template v-if="!preventRemoval"> + <user-deletion-obstacles-list + v-if="hasObstaclesToUserDeletion" + :obstacles="userDeletionObstacles.obstacles" + :user-name="userDeletionObstacles.name" + /> - <user-deletion-obstacles-list - v-if="hasObstaclesToUserDeletion" - :obstacles="userDeletionObstacles.obstacles" - :user-name="userDeletionObstacles.name" - /> - - <input ref="method" type="hidden" name="_method" value="delete" /> - <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> - <gl-form-checkbox v-if="isGroupMember" name="remove_sub_memberships"> - {{ __('Also remove direct user membership from subgroups and projects') }} - </gl-form-checkbox> - <gl-form-checkbox v-if="hasWorkspaceAccess" name="unassign_issuables"> - {{ __('Also unassign this user from related issues and merge requests') }} - </gl-form-checkbox> + <input ref="method" type="hidden" name="_method" value="delete" /> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + <gl-form-checkbox v-if="isGroupMember" name="remove_sub_memberships"> + {{ __('Also remove direct user membership from subgroups and projects') }} + </gl-form-checkbox> + <gl-form-checkbox v-if="hasWorkspaceAccess" name="unassign_issuables"> + {{ __('Also unassign this user from related issues and merge requests') }} + </gl-form-checkbox> + </template> </form> </gl-modal> </template> diff --git a/app/assets/javascripts/members/components/table/created_at.vue b/app/assets/javascripts/members/components/table/created_at.vue index 0bad70894f9..44d124ad0db 100644 --- a/app/assets/javascripts/members/components/table/created_at.vue +++ b/app/assets/javascripts/members/components/table/created_at.vue @@ -1,10 +1,10 @@ <script> import { GlSprintf } from '@gitlab/ui'; -import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import UserDate from '~/vue_shared/components/user_date.vue'; export default { name: 'CreatedAt', - components: { GlSprintf, TimeAgoTooltip }, + components: { GlSprintf, UserDate }, props: { date: { type: String, @@ -29,12 +29,12 @@ export default { <span> <gl-sprintf v-if="showCreatedBy" :message="s__('Members|%{time} by %{user}')"> <template #time> - <time-ago-tooltip :time="date" /> + <user-date :date="date" /> </template> <template #user> <a :href="createdBy.webUrl">{{ createdBy.name }}</a> </template> </gl-sprintf> - <time-ago-tooltip v-else :time="date" /> + <user-date v-else :date="date" /> </span> </template> diff --git a/app/assets/javascripts/members/components/table/member_action_buttons.vue b/app/assets/javascripts/members/components/table/member_action_buttons.vue index ecc2ed82ad0..6ec7be608ba 100644 --- a/app/assets/javascripts/members/components/table/member_action_buttons.vue +++ b/app/assets/javascripts/members/components/table/member_action_buttons.vue @@ -3,12 +3,12 @@ import { MEMBER_TYPES, EE_ACTION_BUTTONS } from 'ee_else_ce/members/constants'; import AccessRequestActionButtons from '../action_buttons/access_request_action_buttons.vue'; import GroupActionButtons from '../action_buttons/group_action_buttons.vue'; import InviteActionButtons from '../action_buttons/invite_action_buttons.vue'; -import UserActionButtons from '../action_buttons/user_action_buttons.vue'; +import UserActionDropdown from '../action_dropdowns/user_action_dropdown.vue'; export default { name: 'MemberActionButtons', components: { - UserActionButtons, + UserActionDropdown, GroupActionButtons, InviteActionButtons, AccessRequestActionButtons, @@ -32,15 +32,11 @@ export default { type: Boolean, required: true, }, - isInvitedUser: { - type: Boolean, - required: true, - }, }, computed: { actionButtonComponent() { const dictionary = { - [MEMBER_TYPES.user]: 'user-action-buttons', + [MEMBER_TYPES.user]: 'user-action-dropdown', [MEMBER_TYPES.group]: 'group-action-buttons', [MEMBER_TYPES.invite]: 'invite-action-buttons', [MEMBER_TYPES.accessRequest]: 'access-request-action-buttons', @@ -60,6 +56,5 @@ export default { :member="member" :permissions="permissions" :is-current-user="isCurrentUser" - :is-invited-user="isInvitedUser" /> </template> diff --git a/app/assets/javascripts/members/components/table/member_activity.vue b/app/assets/javascripts/members/components/table/member_activity.vue new file mode 100644 index 00000000000..3b223cb1afa --- /dev/null +++ b/app/assets/javascripts/members/components/table/member_activity.vue @@ -0,0 +1,38 @@ +<script> +import UserDate from '~/vue_shared/components/user_date.vue'; + +export default { + components: { UserDate }, + props: { + member: { + type: Object, + required: true, + }, + }, + computed: { + userCreated() { + return this.member.user?.createdAt; + }, + lastActivity() { + return this.member.user?.lastActivityOn; + }, + }, +}; +</script> + +<template> + <div> + <div v-if="userCreated"> + <strong>{{ s__('Members|User created') }}:</strong> + <user-date :date="userCreated" /> + </div> + <div v-if="member.createdAt"> + <strong>{{ s__('Members|Access granted') }}:</strong> + <user-date :date="member.createdAt" /> + </div> + <div v-if="lastActivity"> + <strong>{{ s__('Members|Last activity') }}:</strong> + <user-date :date="lastActivity" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/members/components/table/member_source.vue b/app/assets/javascripts/members/components/table/member_source.vue index 30fcbfcd3f8..ed1971d020b 100644 --- a/app/assets/javascripts/members/components/table/member_source.vue +++ b/app/assets/javascripts/members/components/table/member_source.vue @@ -1,11 +1,19 @@ <script> -import { GlTooltipDirective } from '@gitlab/ui'; +import { GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; export default { name: 'MemberSource', + i18n: { + inherited: __('Inherited'), + directMember: __('Direct member'), + directMemberWithCreatedBy: s__('Members|Direct member by %{createdBy}'), + inheritedMemberWithCreatedBy: s__('Members|%{group} by %{createdBy}'), + }, directives: { GlTooltip: GlTooltipDirective, }, + components: { GlSprintf }, props: { memberSource: { type: Object, @@ -15,13 +23,40 @@ export default { type: Boolean, required: true, }, + createdBy: { + type: Object, + required: false, + default: null, + }, + }, + computed: { + showCreatedBy() { + return this.createdBy?.name && this.createdBy?.webUrl; + }, + messageWithCreatedBy() { + return this.isDirectMember + ? this.$options.i18n.directMemberWithCreatedBy + : this.$options.i18n.inheritedMemberWithCreatedBy; + }, }, }; </script> <template> - <span v-if="isDirectMember">{{ __('Direct member') }}</span> - <a v-else v-gl-tooltip.hover :title="__('Inherited')" :href="memberSource.webUrl">{{ + <span v-if="showCreatedBy"> + <gl-sprintf :message="messageWithCreatedBy"> + <template #group> + <a v-gl-tooltip.hover="$options.i18n.inherited" :href="memberSource.webUrl">{{ + memberSource.fullName + }}</a> + </template> + <template #createdBy> + <a :href="createdBy.webUrl">{{ createdBy.name }}</a> + </template> + </gl-sprintf> + </span> + <span v-else-if="isDirectMember">{{ $options.i18n.directMember }}</span> + <a v-else v-gl-tooltip.hover="$options.i18n.inherited" :href="memberSource.webUrl">{{ memberSource.fullName }}</a> </template> diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index 0512bc04085..8f03a298e63 100644 --- a/app/assets/javascripts/members/components/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -2,14 +2,20 @@ import { GlTable, GlBadge, GlPagination } from '@gitlab/ui'; import { mapState } from 'vuex'; import MembersTableCell from 'ee_else_ce/members/components/table/members_table_cell.vue'; -import { canUnban, canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils'; +import { + canDisableTwoFactor, + canUnban, + canOverride, + canRemove, + canRemoveBlockedByLastOwner, + canResend, + canUpdate, +} from 'ee_else_ce/members/utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; -import UserDate from '~/vue_shared/components/user_date.vue'; import { FIELD_KEY_ACTIONS, FIELDS, ACTIVE_TAB_QUERY_PARAM_NAME, - TAB_QUERY_PARAM_VALUES, MEMBER_STATE_AWAITING, MEMBER_STATE_ACTIVE, USER_STATE_BLOCKED, @@ -23,6 +29,7 @@ import ExpirationDatepicker from './expiration_datepicker.vue'; import MemberActionButtons from './member_action_buttons.vue'; import MemberAvatar from './member_avatar.vue'; import MemberSource from './member_source.vue'; +import MemberActivity from './member_activity.vue'; import RoleDropdown from './role_dropdown.vue'; export default { @@ -40,11 +47,13 @@ export default { RemoveGroupLinkModal, RemoveMemberModal, ExpirationDatepicker, - UserDate, + MemberActivity, + DisableTwoFactorModal: () => + import('ee_component/members/components/modals/disable_two_factor_modal.vue'), LdapOverrideConfirmationModal: () => import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'), }, - inject: ['namespace', 'currentUserId'], + inject: ['namespace', 'currentUserId', 'canManageMembers'], props: { tabQueryParamValue: { type: String, @@ -80,18 +89,17 @@ export default { return paramName && currentPage && perPage && totalItems; }, - isInvitedUser() { - return this.tabQueryParamValue === TAB_QUERY_PARAM_VALUES.invite; - }, }, methods: { hasActionButtons(member) { return ( canRemove(member) || + canRemoveBlockedByLastOwner(member, this.canManageMembers) || canResend(member) || canUpdate(member, this.currentUserId) || canOverride(member) || - canUnban(member) + canUnban(member) || + canDisableTwoFactor(member) ); }, showField(field) { @@ -249,7 +257,11 @@ export default { <template #cell(source)="{ item: member }"> <members-table-cell #default="{ isDirectMember }" :member="member"> - <member-source :is-direct-member="isDirectMember" :member-source="member.source" /> + <member-source + :is-direct-member="isDirectMember" + :member-source="member.source" + :created-by="member.createdBy" + /> </members-table-cell> </template> @@ -281,12 +293,8 @@ export default { </members-table-cell> </template> - <template #cell(userCreatedAt)="{ item: member }"> - <user-date :date="member.user.createdAt" /> - </template> - - <template #cell(lastActivityOn)="{ item: member }"> - <user-date :date="member.user.lastActivityOn" /> + <template #cell(activity)="{ item: member }"> + <member-activity :member="member" /> </template> <template #cell(actions)="{ item: member }"> @@ -294,7 +302,6 @@ export default { <member-action-buttons :member-type="memberType" :is-current-user="isCurrentUser" - :is-invited-user="isInvitedUser" :permissions="permissions" :member="member" /> @@ -317,6 +324,7 @@ export default { :label-prev-page="__('Go to previous page')" align="center" /> + <disable-two-factor-modal /> <remove-group-link-modal /> <remove-member-modal /> <ldap-override-confirmation-modal /> diff --git a/app/assets/javascripts/members/components/table/members_table_cell.vue b/app/assets/javascripts/members/components/table/members_table_cell.vue index 51eff428d63..407cbc55dd3 100644 --- a/app/assets/javascripts/members/components/table/members_table_cell.vue +++ b/app/assets/javascripts/members/components/table/members_table_cell.vue @@ -5,13 +5,14 @@ import { isDirectMember, isCurrentUser, canRemove, + canRemoveBlockedByLastOwner, canResend, canUpdate, } from '../../utils'; export default { name: 'MembersTableCell', - inject: ['currentUserId'], + inject: ['currentUserId', 'canManageMembers'], props: { member: { type: Object, @@ -45,6 +46,9 @@ export default { isCurrentUser() { return isCurrentUser(this.member, this.currentUserId); }, + canRemoveBlockedByLastOwner() { + return canRemoveBlockedByLastOwner(this.member, this.canManageMembers); + }, canRemove() { return canRemove(this.member); }, @@ -62,6 +66,7 @@ export default { isCurrentUser: this.isCurrentUser, permissions: { canRemove: this.canRemove, + canRemoveBlockedByLastOwner: this.canRemoveBlockedByLastOwner, canResend: this.canResend, canUpdate: this.canUpdate, }, diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue index 6cd8bf57313..70808587d56 100644 --- a/app/assets/javascripts/members/components/table/role_dropdown.vue +++ b/app/assets/javascripts/members/components/table/role_dropdown.vue @@ -2,7 +2,9 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { mapActions } from 'vuex'; +import * as Sentry from '@sentry/browser'; import { s__ } from '~/locale'; +import { guestOverageConfirmAction } from 'ee_else_ce/members/guest_overage_confirm_action'; export default { name: 'RoleDropdown', @@ -11,7 +13,7 @@ export default { GlDropdownItem, LdapDropdownItem: () => import('ee_component/members/components/ldap/ldap_dropdown_item.vue'), }, - inject: ['namespace'], + inject: ['namespace', 'group'], props: { member: { type: Object, @@ -30,7 +32,7 @@ export default { }, computed: { disabled() { - return this.busy || (this.permissions.canOverride && !this.member.isOverridden); + return this.permissions.canOverride && !this.member.isOverridden; }, }, mounted() { @@ -50,22 +52,45 @@ export default { return dispatch(`${this.namespace}/updateMemberRole`, payload); }, }), - handleSelect(value, name) { - if (value === this.member.accessLevel.integerValue) { + async handleOverageConfirm(currentRoleValue, newRoleValue, newRoleName) { + return guestOverageConfirmAction({ + currentRoleValue, + newRoleValue, + newRoleName, + group: this.group, + memberId: this.member.id, + memberType: this.namespace, + }); + }, + async handleSelect(newRoleValue, newRoleName) { + const currentRoleValue = this.member.accessLevel.integerValue; + if (newRoleValue === currentRoleValue) { return; } this.busy = true; + const confirmed = await this.handleOverageConfirm( + currentRoleValue, + newRoleValue, + newRoleName, + ); + if (!confirmed) { + this.busy = false; + return; + } + this.updateMemberRole({ memberId: this.member.id, - accessLevel: { integerValue: value, stringValue: name }, + accessLevel: { integerValue: newRoleValue, stringValue: newRoleName }, }) .then(() => { this.$toast.show(s__('Members|Role updated successfully.')); - this.busy = false; }) - .catch(() => { + .catch((error) => { + Sentry.captureException(error); + }) + .finally(() => { this.busy = false; }); }, @@ -80,6 +105,7 @@ export default { :text="member.accessLevel.stringValue" :header-text="__('Change role')" :disabled="disabled" + :loading="busy" > <gl-dropdown-item v-for="(value, name) in member.validRoles" diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js index dab544c7cbc..68c5831db62 100644 --- a/app/assets/javascripts/members/constants.js +++ b/app/assets/javascripts/members/constants.js @@ -20,6 +20,7 @@ export const FIELD_KEY_MAX_ROLE = 'maxRole'; export const FIELD_KEY_USER_CREATED_AT = 'userCreatedAt'; export const FIELD_KEY_LAST_ACTIVITY_ON = 'lastActivityOn'; export const FIELD_KEY_EXPIRATION = 'expiration'; +export const FIELD_KEY_ACTIVITY = 'activity'; export const FIELD_KEY_LAST_SIGN_IN = 'lastSignIn'; export const FIELD_KEY_ACTIONS = 'actions'; @@ -41,8 +42,6 @@ export const FIELDS = [ { key: FIELD_KEY_GRANTED, label: __('Access granted'), - thClass: 'col-meta', - tdClass: 'col-meta', sort: { asc: 'last_joined', desc: 'oldest_joined', @@ -77,8 +76,14 @@ export const FIELDS = [ tdClass: 'col-expiration', }, { + key: FIELD_KEY_ACTIVITY, + label: s__('Members|Activity'), + thClass: 'col-activity', + tdClass: 'col-activity', + }, + { key: FIELD_KEY_USER_CREATED_AT, - label: __('Created on'), + label: s__('Members|User created'), sort: { asc: 'oldest_created_user', desc: 'recent_created_user', @@ -158,6 +163,12 @@ export const MEMBER_TYPES = { accessRequest: 'accessRequest', }; +// `app/models/members/group_member.rb` +export const MEMBER_MODEL_TYPE_GROUP_MEMBER = 'GroupMember'; + +// `app/models/members/project_member.rb` +export const MEMBER_MODEL_TYPE_PROJECT_MEMBER = 'ProjectMember'; + export const TAB_QUERY_PARAM_VALUES = { group: 'groups', invite: 'invited', diff --git a/app/assets/javascripts/members/guest_overage_confirm_action.js b/app/assets/javascripts/members/guest_overage_confirm_action.js new file mode 100644 index 00000000000..2205c3ad792 --- /dev/null +++ b/app/assets/javascripts/members/guest_overage_confirm_action.js @@ -0,0 +1,3 @@ +export const guestOverageConfirmAction = () => { + return true; +}; diff --git a/app/assets/javascripts/members/index.js b/app/assets/javascripts/members/index.js index 359239c5c0c..c7398127727 100644 --- a/app/assets/javascripts/members/index.js +++ b/app/assets/javascripts/members/index.js @@ -21,6 +21,8 @@ export const initMembersApp = (el, options) => { canExportMembers, canFilterByEnterprise, exportCsvPath, + groupName, + groupPath, ...vuexStoreAttributes } = parseDataAttributes(el); @@ -66,6 +68,10 @@ export const initMembersApp = (el, options) => { canFilterByEnterprise, canExportMembers, exportCsvPath, + group: { + name: groupName, + path: groupPath, + }, }, render: (createElement) => createElement('members-tabs'), }); diff --git a/app/assets/javascripts/members/utils.js b/app/assets/javascripts/members/utils.js index bf87ab53d36..09e4b5e8a6f 100644 --- a/app/assets/javascripts/members/utils.js +++ b/app/assets/javascripts/members/utils.js @@ -51,6 +51,9 @@ export const canRemove = (member) => { return isDirectMember(member) && member.canRemove; }; +export const canRemoveBlockedByLastOwner = (member, canManageMembers) => + isDirectMember(member) && canManageMembers && member.isLastOwner; + export const canResend = (member) => { return Boolean(member.invite?.canResend); }; @@ -106,6 +109,9 @@ export const buildSortHref = ({ }; // Defined in `ee/app/assets/javascripts/members/utils.js` +export const canDisableTwoFactor = () => false; + +// Defined in `ee/app/assets/javascripts/members/utils.js` export const canOverride = () => false; // Defined in `ee/app/assets/javascripts/members/utils.js` diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 5a1410ceeba..46ee8fecfc5 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -1,4 +1,4 @@ -/* eslint-disable no-new, class-methods-use-this */ +/* eslint-disable class-methods-use-this */ import $ from 'jquery'; import Vue from 'vue'; import { createAlert } from '~/flash'; @@ -134,8 +134,8 @@ function destroyPipelines(app) { return null; } -function loadDiffs({ url, sticky }) { - return axios.get(`${url}.json${location.search}`).then(({ data }) => { +function loadDiffs({ url, sticky, tabs }) { + return axios.get(url).then(({ data }) => { const $container = $('#diffs'); $container.html(data.html); initDiffStatsDropdown(sticky); @@ -143,7 +143,9 @@ function loadDiffs({ url, sticky }) { localTimeAgo(document.querySelectorAll('#diffs .js-timeago')); syntaxHighlight($('#diffs .js-syntax-highlight')); - new Diff(); + tabs.createDiff(); + tabs.setHubToDiff(); + scrollToContainer('#diffs'); $('.diff-file').each((i, el) => { @@ -204,6 +206,7 @@ export default class MergeRequestTabs { this.currentTab = null; this.diffsLoaded = false; + this.diffsClass = null; this.commitsLoaded = false; this.fixedLayoutPref = null; this.eventHub = createEventHub(); @@ -211,6 +214,7 @@ export default class MergeRequestTabs { this.setUrl = setUrl !== undefined ? setUrl : true; this.setCurrentAction = this.setCurrentAction.bind(this); + this.switchViewType = this.switchViewType.bind(this); this.tabShown = this.tabShown.bind(this); this.clickTab = this.clickTab.bind(this); @@ -230,11 +234,13 @@ export default class MergeRequestTabs { this.tabShown(action, location.href); this.eventHub.$emit('MergeRequestTabChange', action); }); + this.eventHub.$on('diff:switch-view-type', this.switchViewType); } // Used in tests unbindEvents() { $('.merge-request-tabs a[data-toggle="tabvue"]').off('click', this.clickTab); + this.eventHub.$off('diff:switch-view-type', this.switchViewType); } storeScroll() { @@ -341,7 +347,7 @@ export default class MergeRequestTabs { in practice, this only occurs when comparing commits in the new merge request form page. */ - this.loadDiff(href); + this.loadDiff({ endpoint: href, strip: true }); } // this.hideSidebar(); this.expandViewContainer(); @@ -503,17 +509,20 @@ export default class MergeRequestTabs { } // load the diff tab content from the backend - loadDiff(source) { + loadDiff({ endpoint, strip = true }) { if (this.diffsLoaded) { document.dispatchEvent(new CustomEvent('scroll')); return; } + // We extract pathname for the current Changes tab anchor href + // some pages like MergeRequestsController#new has query parameters on that anchor + const diffUrl = strip ? `${parseUrlPathname(endpoint)}.json${location.search}` : endpoint; + loadDiffs({ - // We extract pathname for the current Changes tab anchor href - // some pages like MergeRequestsController#new has query parameters on that anchor - url: parseUrlPathname(source), + url: diffUrl, sticky: computeTopOffset(this.mergeRequestTabs), + tabs: this, }) .then(() => { if (this.isDiffAction(this.currentAction)) { @@ -528,6 +537,21 @@ export default class MergeRequestTabs { }); }); } + switchViewType({ source }) { + this.diffsLoaded = false; + + this.loadDiff({ endpoint: source, strip: false }); + } + createDiff() { + if (!this.diffsClass) { + this.diffsClass = new Diff({ mergeRequestEventHub: this.eventHub }); + } + } + setHubToDiff() { + if (this.diffsClass) { + this.diffsClass.mrHub = this.eventHub; + } + } diffViewType() { return $('.js-diff-view-buttons button.active').data('viewType'); diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue index 4a675cf7563..6af1baaa37e 100644 --- a/app/assets/javascripts/merge_requests/components/sticky_header.vue +++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue @@ -56,11 +56,7 @@ export default { }, watch: { discussionTabCounter(val) { - if (this.glFeatures.paginatedMrDiscussions) { - if (this.doneFetchingBatchDiscussions) { - this.discussionCounter = val; - } - } else { + if (this.doneFetchingBatchDiscussions) { this.discussionCounter = val; } }, @@ -86,8 +82,8 @@ export default { @disappear="setStickyHeaderVisible(true)" > <div - v-if="isStickyHeaderVisible" class="issue-sticky-header merge-request-sticky-header gl-fixed gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-pt-3 gl-display-none gl-md-display-block" + :class="{ 'gl-visibility-hidden': !isStickyHeaderVisible }" > <div class="issue-sticky-header-text gl-display-flex gl-flex-direction-column gl-align-items-center gl-mx-auto gl-px-5" diff --git a/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue b/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue index 5f54f24e24c..0bb2a913dec 100644 --- a/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue +++ b/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue @@ -19,6 +19,25 @@ export default { artifactsLabel: __('Artifacts'), parametersLabel: __('Parameters'), metricsLabel: __('Metrics'), + metadataLabel: __('Metadata'), + }, + computed: { + sections() { + return [ + { + sectionName: this.$options.i18n.parametersLabel, + sectionValues: this.candidate.params, + }, + { + sectionName: this.$options.i18n.metricsLabel, + sectionValues: this.candidate.metrics, + }, + { + sectionName: this.$options.i18n.metadataLabel, + sectionValues: this.candidate.metadata, + }, + ]; + }, }, }; </script> @@ -67,27 +86,18 @@ export default { </td> </tr> - <tr class="divider"></tr> - - <tr v-for="(param, index) in candidate.params" :key="param.name"> - <td v-if="index == 0" class="gl-text-secondary gl-font-weight-bold"> - {{ $options.i18n.parametersLabel }} - </td> - <td v-else></td> - <td class="gl-font-weight-bold">{{ param.name }}</td> - <td>{{ param.value }}</td> - </tr> + <template v-for="{ sectionName, sectionValues } in sections"> + <tr :key="sectionName" class="divider"></tr> - <tr class="divider"></tr> - - <tr v-for="(metric, index) in candidate.metrics" :key="metric.name"> - <td v-if="index == 0" class="gl-text-secondary gl-font-weight-bold"> - {{ $options.i18n.metricsLabel }} - </td> - <td v-else></td> - <td class="gl-font-weight-bold">{{ metric.name }}</td> - <td>{{ metric.value }}</td> - </tr> + <tr v-for="(item, index) in sectionValues" :key="item.name"> + <td v-if="index === 0" class="gl-text-secondary gl-font-weight-bold"> + {{ sectionName }} + </td> + <td v-else></td> + <td class="gl-font-weight-bold">{{ item.name }}</td> + <td>{{ item.value }}</td> + </tr> + </template> </tbody> </table> </div> diff --git a/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue b/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue index f8e269d3b57..5d13122765a 100644 --- a/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue +++ b/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue @@ -1,6 +1,8 @@ <script> -import { GlTable, GlLink } from '@gitlab/ui'; +import { GlTable, GlLink, GlPagination, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; +import { getParameterValues, setUrlParams } from '~/lib/utils/url_utility'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import IncubationAlert from './incubation_alert.vue'; export default { @@ -8,24 +10,55 @@ export default { components: { GlTable, GlLink, + TimeAgo, IncubationAlert, + GlPagination, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['candidates', 'metricNames', 'paramNames', 'pagination'], + data() { + return { + page: parseInt(getParameterValues('page')[0], 10) || 1, + }; }, - inject: ['candidates', 'metricNames', 'paramNames'], computed: { fields() { return [ + { key: 'name', label: this.$options.i18n.nameLabel }, + { key: 'created_at', label: this.$options.i18n.createdAtLabel }, + { key: 'user', label: this.$options.i18n.userLabel }, ...this.paramNames, ...this.metricNames, { key: 'details', label: '' }, { key: 'artifact', label: '' }, ]; }, + displayPagination() { + return this.candidates.length > 0; + }, + prevPage() { + return this.pagination.page > 1 ? this.pagination.page - 1 : null; + }, + nextPage() { + return !this.pagination.isLastPage ? this.pagination.page + 1 : null; + }, + }, + methods: { + generateLink(page) { + return setUrlParams({ page }); + }, }, i18n: { titleLabel: __('Experiment candidates'), emptyStateLabel: __('This experiment has no logged candidates'), artifactsLabel: __('Artifacts'), detailsLabel: __('Details'), + userLabel: __('User'), + createdAtLabel: __('Created at'), + nameLabel: __('Name'), + noDataContent: __('-'), }, }; </script> @@ -43,17 +76,59 @@ export default { :items="candidates" :empty-text="$options.i18n.emptyStateLabel" show-empty - class="gl-mt-0!" + small + class="gl-mt-0! ml-candidate-table" > + <template #cell()="data"> + <div v-gl-tooltip.hover :title="data.value">{{ data.value }}</div> + </template> + <template #cell(artifact)="data"> - <gl-link v-if="data.value" :href="data.value" target="_blank">{{ - $options.i18n.artifactsLabel - }}</gl-link> + <gl-link + v-if="data.value" + v-gl-tooltip.hover + :href="data.value" + target="_blank" + :title="$options.i18n.artifactsLabel" + >{{ $options.i18n.artifactsLabel }}</gl-link + > + <div v-else v-gl-tooltip.hover :title="$options.i18n.artifactsLabel"> + {{ $options.i18n.noDataContent }} + </div> </template> <template #cell(details)="data"> - <gl-link :href="data.value">{{ $options.i18n.detailsLabel }}</gl-link> + <gl-link v-gl-tooltip.hover :href="data.value" :title="$options.i18n.detailsLabel">{{ + $options.i18n.detailsLabel + }}</gl-link> + </template> + + <template #cell(created_at)="data"> + <time-ago v-gl-tooltip.hover :time="data.value" :title="data.value" /> + </template> + + <template #cell(user)="data"> + <gl-link + v-if="data.value" + v-gl-tooltip.hover + :href="data.value.path" + :title="data.value.username" + >@{{ data.value.username }}</gl-link + > + <div v-else>{{ $options.i18n.noDataContent }}</div> </template> </gl-table> + + <gl-pagination + v-if="displayPagination" + v-model="pagination.page" + :prev-page="prevPage" + :next-page="nextPage" + :total-items="pagination.totalItems" + :per-page="pagination.perPage" + :link-gen="generateLink" + align="center" + class="w-100" + /> </div> </template> diff --git a/app/assets/javascripts/monitoring/requests/index.js b/app/assets/javascripts/monitoring/requests/index.js index 8b65eec051f..29786a79c56 100644 --- a/app/assets/javascripts/monitoring/requests/index.js +++ b/app/assets/javascripts/monitoring/requests/index.js @@ -1,7 +1,9 @@ import axios from '~/lib/utils/axios_utils'; import { backOff } from '~/lib/utils/common_utils'; -import statusCodes, { +import { + HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_NO_CONTENT, + HTTP_STATUS_SERVICE_UNAVAILABLE, HTTP_STATUS_UNPROCESSABLE_ENTITY, } from '~/lib/utils/http_status'; import { PROMETHEUS_TIMEOUT } from '../constants'; @@ -36,9 +38,9 @@ export const getPrometheusQueryData = (prometheusEndpoint, params, opts) => // https://prometheus.io/docs/prometheus/latest/querying/api/#format-overview const { response = {} } = error; if ( - response.status === statusCodes.BAD_REQUEST || + response.status === HTTP_STATUS_BAD_REQUEST || response.status === HTTP_STATUS_UNPROCESSABLE_ENTITY || - response.status === statusCodes.SERVICE_UNAVAILABLE + response.status === HTTP_STATUS_SERVICE_UNAVAILABLE ) { const { data } = response; if (data?.status === 'error' && data?.error) { diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index e0eaf76b5f6..5fab292b6df 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -1,7 +1,7 @@ import { pick } from 'lodash'; import Vue from 'vue'; import { BACKOFF_TIMEOUT } from '~/lib/utils/common_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_SERVICE_UNAVAILABLE } from '~/lib/utils/http_status'; import { dashboardEmptyStates, endpointKeys, initialStateKeys, metricStates } from '../constants'; import * as types from './mutation_types'; import { mapToDashboardViewModel, mapPanelToViewModel, normalizeQueryResponseData } from './utils'; @@ -43,9 +43,9 @@ const emptyStateFromError = (error) => { // Axios error responses const { response } = error; - if (response && response.status === httpStatusCodes.SERVICE_UNAVAILABLE) { + if (response && response.status === HTTP_STATUS_SERVICE_UNAVAILABLE) { return metricStates.CONNECTION_FAILED; - } else if (response && response.status === httpStatusCodes.BAD_REQUEST) { + } else if (response && response.status === HTTP_STATUS_BAD_REQUEST) { // Note: "error.response.data.error" may contain Prometheus error information return metricStates.BAD_QUERY; } diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js index e10605609b0..f5f10aa4a9b 100644 --- a/app/assets/javascripts/mr_notes/init_notes.js +++ b/app/assets/javascripts/mr_notes/init_notes.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import { mapActions, mapState, mapGetters } from 'vuex'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { parseBoolean } from '~/lib/utils/common_utils'; import store from '~/mr_notes/stores'; import notesEventHub from '~/notes/event_hub'; @@ -9,6 +10,13 @@ import { getNotesFilterData } from '../notes/utils/get_notes_filter_data'; import initWidget from '../vue_merge_request_widget'; export default () => { + requestIdleCallback( + () => { + renderGFM(document.getElementById('diff-notes-app')); + }, + { timeout: 500 }, + ); + const el = document.getElementById('js-vue-mr-discussions'); if (!el) { return; diff --git a/app/assets/javascripts/nav/components/new_nav_toggle.vue b/app/assets/javascripts/nav/components/new_nav_toggle.vue index ef59140115d..7b0076cc5d4 100644 --- a/app/assets/javascripts/nav/components/new_nav_toggle.vue +++ b/app/assets/javascripts/nav/components/new_nav_toggle.vue @@ -3,6 +3,7 @@ import { GlBadge, GlToggle } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; import { createAlert } from '~/flash'; import { s__ } from '~/locale'; +import Tracking from '~/tracking'; export default { i18n: { @@ -34,9 +35,19 @@ export default { }; }, methods: { - async toggleNav() { + toggleNav() { + this.isEnabled = !this.isEnabled; + this.updateAndReload(); + }, + async updateAndReload() { try { - await axios.put(this.endpoint, { user: { use_new_navigation: !this.enabled } }); + await axios.put(this.endpoint, { user: { use_new_navigation: this.isEnabled } }); + + Tracking.event(undefined, 'click_toggle', { + label: this.enabled ? 'disable_new_nav_beta' : 'enable_new_nav_beta', + property: 'navigation', + }); + window.location.reload(); } catch (error) { createAlert({ @@ -55,17 +66,15 @@ export default { class="gl-px-4 gl-py-2 gl-display-flex gl-justify-content-space-between gl-align-items-center" > <b>{{ $options.i18n.sectionTitle }}</b> - <gl-badge>{{ $options.i18n.badgeLabel }}</gl-badge> + <gl-badge variant="info">{{ $options.i18n.badgeLabel }}</gl-badge> </div> - <div class="menu-item gl-display-flex! gl-justify-content-space-between gl-align-items-center"> + <div + class="menu-item gl-cursor-pointer gl-display-flex! gl-justify-content-space-between gl-align-items-center" + @click.prevent.stop="toggleNav" + > {{ $options.i18n.toggleMenuItemLabel }} - <gl-toggle - v-model="isEnabled" - :label="$options.i18n.toggleLabel" - label-position="hidden" - @change="toggleNav" - /> + <gl-toggle :value="isEnabled" :label="$options.i18n.toggleLabel" label-position="hidden" /> </div> </li> </template> diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 2ccb9a0b514..c6e7117cf2e 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -312,7 +312,7 @@ export default { if (this.isLoggedIn) { const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType)); - this.autosave = new Autosave($(this.$refs.textarea), [ + this.autosave = new Autosave(this.$refs.textarea, [ this.$options.i18n.note, noteableType, this.getNoteableData.id, diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 36f7d720e48..79b6139d4b1 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -1,6 +1,7 @@ <script> import { GlIcon, GlBadge, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { mapActions } from 'vuex'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __, s__ } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -74,6 +75,12 @@ export default { }; }, computed: { + authorId() { + return getIdFromGraphQLId(this.author.id); + }, + authorHref() { + return this.author.path || this.author.webUrl; + }, toggleChevronIconName() { return this.expanded ? 'chevron-up' : 'chevron-down'; }, @@ -145,9 +152,9 @@ export default { <template v-if="hasAuthor"> <a ref="authorNameLink" - :href="author.path" + :href="authorHref" :class="authorLinkClasses" - :data-user-id="author.id" + :data-user-id="authorId" :data-username="author.username" > <span class="note-header-author-name gl-font-weight-bold"> diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js index 61cb4ab2a10..17272d5abef 100644 --- a/app/assets/javascripts/notes/mixins/autosave.js +++ b/app/assets/javascripts/notes/mixins/autosave.js @@ -1,4 +1,3 @@ -import $ from 'jquery'; import { s__ } from '~/locale'; import Autosave from '~/autosave'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; @@ -16,7 +15,7 @@ export default { keys = keys.concat(extraKeys); } - this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), keys); + this.autosave = new Autosave(this.$refs.noteForm.$refs.textarea, keys); }, resetAutoSave() { this.autosave.reset(); diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index d290a8ccb84..5cad091ce2c 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -101,7 +101,7 @@ export const fetchDiscussions = ( if ( getters.noteableType === constants.ISSUE_NOTEABLE_TYPE || - window.gon?.features?.paginatedMrDiscussions + getters.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE ) { return dispatch('fetchDiscussionsBatch', { path, config, perPage: 20 }); } diff --git a/app/assets/javascripts/notifications/components/notification_email_listbox_input.vue b/app/assets/javascripts/notifications/components/notification_email_listbox_input.vue new file mode 100644 index 00000000000..5d5524deb0d --- /dev/null +++ b/app/assets/javascripts/notifications/components/notification_email_listbox_input.vue @@ -0,0 +1,46 @@ +<script> +import ListboxInput from '~/vue_shared/components/listbox_input/listbox_input.vue'; + +export default { + components: { + ListboxInput, + }, + inject: ['label', 'name', 'emails', 'emptyValueText', 'value', 'disabled'], + data() { + return { + selected: this.value, + }; + }, + computed: { + options() { + return [ + { + value: '', + text: this.emptyValueText, + }, + ...this.emails.map((email) => ({ + text: email, + value: email, + })), + ]; + }, + }, + methods: { + async onSelect() { + await this.$nextTick(); + this.$el.closest('form').submit(); + }, + }, +}; +</script> + +<template> + <listbox-input + v-model="selected" + :label="label" + :name="name" + :items="options" + :disabled="disabled" + @select="onSelect" + /> +</template> diff --git a/app/assets/javascripts/notifications/index.js b/app/assets/javascripts/notifications/index.js index a81f2c2590b..1395084f68c 100644 --- a/app/assets/javascripts/notifications/index.js +++ b/app/assets/javascripts/notifications/index.js @@ -2,10 +2,37 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; import NotificationsDropdown from './components/notifications_dropdown.vue'; +import NotificationEmailListboxInput from './components/notification_email_listbox_input.vue'; Vue.use(GlToast); +const initNotificationEmailListboxInputs = () => { + const els = [...document.querySelectorAll('.js-notification-email-listbox-input')]; + + els.forEach((el, index) => { + const { label, name, emptyValueText, value = '' } = el.dataset; + + return new Vue({ + el, + name: `NotificationEmailListboxInputRoot${index + 1}`, + provide: { + label, + name, + emails: JSON.parse(el.dataset.emails), + emptyValueText, + value, + disabled: parseBoolean(el.dataset.disabled), + }, + render(h) { + return h(NotificationEmailListboxInput); + }, + }); + }); +}; + export default () => { + initNotificationEmailListboxInputs(); + const containers = document.querySelectorAll('.js-vue-notification-dropdown'); if (!containers.length) return false; diff --git a/app/assets/javascripts/observability/components/observability_app.vue b/app/assets/javascripts/observability/components/observability_app.vue index 33d23ea043b..ff9cf6ff6c5 100644 --- a/app/assets/javascripts/observability/components/observability_app.vue +++ b/app/assets/javascripts/observability/components/observability_app.vue @@ -2,7 +2,7 @@ import { darkModeEnabled } from '~/lib/utils/color_utils'; import { setUrlParams } from '~/lib/utils/url_utility'; -import { MESSAGE_EVENT_TYPE, OBSERVABILITY_ROUTES, SKELETON_VARIANT } from '../constants'; +import { MESSAGE_EVENT_TYPE, SKELETON_VARIANTS_BY_ROUTE } from '../constants'; import ObservabilitySkeleton from './skeleton/index.vue'; export default { @@ -23,16 +23,16 @@ export default { ); }, getSkeletonVariant() { - switch (this.$route.path) { - case OBSERVABILITY_ROUTES.DASHBOARDS: - return SKELETON_VARIANT.DASHBOARDS; - case OBSERVABILITY_ROUTES.EXPLORE: - return SKELETON_VARIANT.EXPLORE; - case OBSERVABILITY_ROUTES.MANAGE: - return SKELETON_VARIANT.MANAGE; - default: - return SKELETON_VARIANT.DASHBOARDS; - } + const [, variant] = + Object.entries(SKELETON_VARIANTS_BY_ROUTE).find(([path]) => + this.$route.path.endsWith(path), + ) || []; + + const DEFAULT_SKELETON = 'dashboards'; + + if (!variant) return DEFAULT_SKELETON; + + return variant; }, }, mounted() { @@ -51,7 +51,7 @@ export default { } = e; switch (type) { case MESSAGE_EVENT_TYPE.GOUI_LOADED: - this.$refs.iframeSkeleton.handleSkeleton(); + this.$refs.observabilitySkeleton.onContentLoaded(); break; case MESSAGE_EVENT_TYPE.GOUI_ROUTE_UPDATE: this.routeUpdateHandler(payload); @@ -80,7 +80,7 @@ export default { </script> <template> - <observability-skeleton ref="iframeSkeleton" :variant="getSkeletonVariant"> + <observability-skeleton ref="observabilitySkeleton" :variant="getSkeletonVariant"> <iframe id="observability-ui-iframe" data-testid="observability-ui-iframe" diff --git a/app/assets/javascripts/observability/components/skeleton/index.vue b/app/assets/javascripts/observability/components/skeleton/index.vue index 1e2671c8166..c8f196a43f4 100644 --- a/app/assets/javascripts/observability/components/skeleton/index.vue +++ b/app/assets/javascripts/observability/components/skeleton/index.vue @@ -1,17 +1,32 @@ <script> -import { GlSkeletonLoader } from '@gitlab/ui'; -import { SKELETON_VARIANT } from '../../constants'; +import { GlSkeletonLoader, GlAlert } from '@gitlab/ui'; + +import { + SKELETON_VARIANTS_BY_ROUTE, + SKELETON_STATE, + DEFAULT_TIMERS, + OBSERVABILITY_ROUTES, + TIMEOUT_ERROR_LABEL, + TIMEOUT_ERROR_MESSAGE, +} from '../../constants'; import DashboardsSkeleton from './dashboards.vue'; import ExploreSkeleton from './explore.vue'; import ManageSkeleton from './manage.vue'; export default { - SKELETON_VARIANT, components: { GlSkeletonLoader, DashboardsSkeleton, ExploreSkeleton, ManageSkeleton, + GlAlert, + }, + SKELETON_VARIANTS_BY_ROUTE, + SKELETON_STATE, + OBSERVABILITY_ROUTES, + i18n: { + TIMEOUT_ERROR_LABEL, + TIMEOUT_ERROR_MESSAGE, }, props: { variant: { @@ -22,65 +37,94 @@ export default { }, data() { return { - loading: null, - timerId: null, + state: null, + loadingTimeout: null, + errorTimeout: null, }; }, mounted() { - this.timerId = setTimeout(() => { - /** - * If observability UI is not loaded then this.loading would be null - * we will show skeleton in that case - */ - if (this.loading !== false) { - this.showSkeleton(); - } - }, 500); + this.setLoadingTimeout(); + this.setErrorTimeout(); + }, + destroyed() { + clearTimeout(this.loadingTimeout); + clearTimeout(this.errorTimeout); }, methods: { - handleSkeleton() { - if (this.loading === null) { + onContentLoaded() { + clearTimeout(this.errorTimeout); + clearTimeout(this.loadingTimeout); + + this.hideSkeleton(); + }, + setLoadingTimeout() { + this.loadingTimeout = setTimeout(() => { /** - * If observability UI content loads with in 500ms - * do not show skeleton. + * If content is not loaded within CONTENT_WAIT_MS, + * show the skeleton */ - clearTimeout(this.timerId); - return; - } - - /** - * If observability UI content loads after 500ms - * wait for 400ms to hide skeleton. - * This is mostly to avoid the flashing effect If content loads imediately after skeleton - */ - setTimeout(this.hideSkeleton, 400); + if (this.state !== SKELETON_STATE.HIDDEN) { + this.showSkeleton(); + } + }, DEFAULT_TIMERS.CONTENT_WAIT_MS); + }, + setErrorTimeout() { + this.errorTimeout = setTimeout(() => { + /** + * If content is not loaded within TIMEOUT_MS, + * show the error dialog + */ + if (this.state !== SKELETON_STATE.HIDDEN) { + this.showError(); + } + }, DEFAULT_TIMERS.TIMEOUT_MS); }, hideSkeleton() { - this.loading = false; + this.state = SKELETON_STATE.HIDDEN; }, showSkeleton() { - this.loading = true; + this.state = SKELETON_STATE.VISIBLE; + }, + showError() { + this.state = SKELETON_STATE.ERROR; + }, + + isSkeletonShown(route) { + return this.variant === SKELETON_VARIANTS_BY_ROUTE[route]; }, }, }; </script> <template> <div class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch"> - <div v-show="loading" class="gl-px-5"> - <dashboards-skeleton v-if="variant === $options.SKELETON_VARIANT.DASHBOARDS" /> - <explore-skeleton v-else-if="variant === $options.SKELETON_VARIANT.EXPLORE" /> - <manage-skeleton v-else-if="variant === $options.SKELETON_VARIANT.MANAGE" /> + <transition name="fade"> + <div v-if="state === $options.SKELETON_STATE.VISIBLE" class="gl-px-5"> + <dashboards-skeleton v-if="isSkeletonShown($options.OBSERVABILITY_ROUTES.DASHBOARDS)" /> + <explore-skeleton v-else-if="isSkeletonShown($options.OBSERVABILITY_ROUTES.EXPLORE)" /> + <manage-skeleton v-else-if="isSkeletonShown($options.OBSERVABILITY_ROUTES.MANAGE)" /> - <gl-skeleton-loader v-else> - <rect y="2" width="10" height="8" /> - <rect y="2" x="15" width="15" height="8" /> - <rect y="2" x="35" width="15" height="8" /> - <rect y="15" width="400" height="30" /> - </gl-skeleton-loader> - </div> + <gl-skeleton-loader v-else> + <rect y="2" width="10" height="8" /> + <rect y="2" x="15" width="15" height="8" /> + <rect y="2" x="35" width="15" height="8" /> + <rect y="15" width="400" height="30" /> + </gl-skeleton-loader> + </div> + </transition> + + <gl-alert + v-if="state === $options.SKELETON_STATE.ERROR" + :title="$options.i18n.TIMEOUT_ERROR_LABEL" + variant="danger" + :dismissible="false" + class="gl-m-5" + > + {{ $options.i18n.TIMEOUT_ERROR_MESSAGE }} + </gl-alert> <div - v-show="!loading" + v-show="state === $options.SKELETON_STATE.HIDDEN" + data-testid="observability-wrapper" class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch" > <slot></slot> diff --git a/app/assets/javascripts/observability/constants.js b/app/assets/javascripts/observability/constants.js index 74dd543e285..e4827dd169f 100644 --- a/app/assets/javascripts/observability/constants.js +++ b/app/assets/javascripts/observability/constants.js @@ -1,16 +1,32 @@ +import { __ } from '~/locale'; + export const MESSAGE_EVENT_TYPE = Object.freeze({ GOUI_LOADED: 'GOUI_LOADED', GOUI_ROUTE_UPDATE: 'GOUI_ROUTE_UPDATE', }); export const OBSERVABILITY_ROUTES = Object.freeze({ - DASHBOARDS: '/groups/gitlab-org/-/observability/dashboards', - EXPLORE: '/groups/gitlab-org/-/observability/explore', - MANAGE: '/groups/gitlab-org/-/observability/manage', + DASHBOARDS: 'observability/dashboards', + EXPLORE: 'observability/explore', + MANAGE: 'observability/manage', +}); + +export const SKELETON_VARIANTS_BY_ROUTE = Object.freeze({ + [OBSERVABILITY_ROUTES.DASHBOARDS]: 'dashboards', + [OBSERVABILITY_ROUTES.EXPLORE]: 'explore', + [OBSERVABILITY_ROUTES.MANAGE]: 'manage', }); -export const SKELETON_VARIANT = Object.freeze({ - DASHBOARDS: 'dashboards', - EXPLORE: 'explore', - MANAGE: 'manage', +export const SKELETON_STATE = Object.freeze({ + ERROR: 'error', + VISIBLE: 'visible', + HIDDEN: 'hidden', }); + +export const DEFAULT_TIMERS = Object.freeze({ + TIMEOUT_MS: 20000, + CONTENT_WAIT_MS: 500, +}); + +export const TIMEOUT_ERROR_LABEL = __('Unable to load the page'); +export const TIMEOUT_ERROR_MESSAGE = __('Reload the page to try again.'); diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue index acf810257e6..38b601ac3ec 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue @@ -95,7 +95,7 @@ export default { return formatDate(this.tag.createdAt, 'isoDate'); }, publishedTime() { - return formatDate(this.tag.createdAt, 'hh:MM Z'); + return formatDate(this.tag.createdAt, 'HH:MM:ss Z'); }, formattedRevision() { // to be removed when API response is adjusted diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue index 23d8e97dd79..4f89d217623 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue @@ -12,7 +12,6 @@ import { REMOVE_REPOSITORY_LABEL, ROW_SCHEDULED_FOR_DELETION, IMAGE_DELETE_SCHEDULED_STATUS, - IMAGE_FAILED_DELETED_STATUS, IMAGE_MIGRATING_STATE, COPY_IMAGE_PATH_TITLE, IMAGE_FULL_PATH_LABEL, @@ -79,9 +78,6 @@ export default { migrating() { return this.item.migrationState === IMAGE_MIGRATING_STATE; }, - failedDelete() { - return this.item.status === IMAGE_FAILED_DELETED_STATUS; - }, tagsCountText() { return n__( 'ContainerRegistry|%{count} Tag', @@ -99,9 +95,6 @@ export default { } return projectPath; }, - routerLinkEvent() { - return this.deleting ? '' : 'click'; - }, deleteButtonTooltipTitle() { return this.migrating ? LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION @@ -121,15 +114,7 @@ export default { </script> <template> - <list-item - v-gl-tooltip="{ - placement: 'left', - disabled: !deleting, - title: $options.i18n.ROW_SCHEDULED_FOR_DELETION, - }" - v-bind="$attrs" - :disabled="deleting" - > + <list-item v-bind="$attrs"> <template #left-primary> <gl-button v-if="!showFullPath" @@ -143,12 +128,13 @@ export default { :aria-label="$options.i18n.IMAGE_FULL_PATH_LABEL" @click="hideButton" /> + <span v-if="deleting" class="gl-text-gray-500">{{ imageName }}</span> <router-link + v-else ref="imageName" class="gl-text-body gl-font-weight-bold" data-testid="details-link" data-qa-selector="registry_image_content" - :event="routerLinkEvent" :to="{ name: 'details', params: { id } }" > {{ imageName }} @@ -163,21 +149,24 @@ export default { </template> <template #left-secondary> <template v-if="!metadataLoading"> - <span class="gl-display-flex gl-align-items-center" data-testid="tags-count"> - <gl-icon name="tag" class="gl-mr-2" /> - <gl-sprintf :message="tagsCountText"> - <template #count> - {{ item.tagsCount }} - </template> - </gl-sprintf> - </span> + <span v-if="deleting">{{ $options.i18n.ROW_SCHEDULED_FOR_DELETION }}</span> + <template v-else> + <span class="gl-display-flex gl-align-items-center" data-testid="tags-count"> + <gl-icon name="tag" class="gl-mr-2" /> + <gl-sprintf :message="tagsCountText"> + <template #count> + {{ item.tagsCount }} + </template> + </gl-sprintf> + </span> - <cleanup-status - v-if="item.expirationPolicyCleanupStatus" - class="ml-2" - :status="item.expirationPolicyCleanupStatus" - :expiration-policy="expirationPolicy" - /> + <cleanup-status + v-if="item.expirationPolicyCleanupStatus" + class="gl-ml-2" + :status="item.expirationPolicyCleanupStatus" + :expiration-policy="expirationPolicy" + /> + </template> </template> <div v-else class="gl-w-full"> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js index 020d78ad364..f2aa4916f48 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js @@ -53,7 +53,6 @@ export const TRACKING_ACTION_CLICK_SHOW_FULL_PATH = 'click_show_full_path'; // Parameters export const IMAGE_DELETE_SCHEDULED_STATUS = 'DELETE_SCHEDULED'; -export const IMAGE_FAILED_DELETED_STATUS = 'DELETE_FAILED'; export const IMAGE_MIGRATING_STATE = 'importing'; export const GRAPHQL_PAGE_SIZE = 10; diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js index 9b062024d03..850dca07a3f 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js @@ -20,7 +20,7 @@ export const apolloProvider = new VueApollo({ ContainerRepositoryDetails: { fields: { tags: { - keyArgs: ['id'], + keyArgs: ['id', 'name', 'sort'], merge: mergeVariables, }, }, diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_history.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_history.vue index e5be98b87f7..06e4c38a179 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_history.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_history.vue @@ -96,9 +96,9 @@ export default { <history-item icon="commit" data-testid="first-pipeline-commit"> <gl-sprintf :message="$options.i18n.createdByCommitText"> <template #link> - <gl-link :href="firstPipeline.project.commit_url" - >#{{ truncate(firstPipeline.sha) }}</gl-link - > + <gl-link :href="firstPipeline.project.commit_url">{{ + truncate(firstPipeline.sha) + }}</gl-link> </template> <template #branch> <strong>{{ firstPipeline.ref }}</strong> @@ -147,7 +147,7 @@ export default { > <gl-sprintf :message="$options.i18n.combinedUpdateText"> <template #link> - <gl-link :href="pipeline.project.commit_url">#{{ truncate(pipeline.sha) }}</gl-link> + <gl-link :href="pipeline.project.commit_url">{{ truncate(pipeline.sha) }}</gl-link> </template> <template #branch> <strong>{{ pipeline.ref }}</strong> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue index 2a1de2ae4a7..011a2668a8b 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue @@ -55,6 +55,7 @@ export default { :action-cancel="$options.modal.cancelAction" :title="$options.i18n.DELETE_PACKAGES_MODAL_TITLE" @primary="$emit('confirm')" + @cancel="$emit('cancel')" > <span>{{ description }}</span> </gl-modal> 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 index a1fc7563de1..663c361819e 100644 --- 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 @@ -159,9 +159,9 @@ export default { <history-item icon="commit" data-testid="first-pipeline-commit"> <gl-sprintf :message="$options.i18n.createdByCommitText"> <template #link> - <gl-link :href="firstPipeline.commitPath" @click="trackCommitClick" - >#{{ truncate(firstPipeline.sha) }}</gl-link - > + <gl-link :href="firstPipeline.commitPath" @click="trackCommitClick">{{ + truncate(firstPipeline.sha) + }}</gl-link> </template> <template #branch> <strong>{{ firstPipeline.ref }}</strong> @@ -212,9 +212,9 @@ export default { > <gl-sprintf :message="$options.i18n.combinedUpdateText"> <template #link> - <gl-link :href="pipeline.commitPath" @click="trackCommitClick" - >#{{ truncate(pipeline.sha) }}</gl-link - > + <gl-link :href="pipeline.commitPath" @click="trackCommitClick">{{ + truncate(pipeline.sha) + }}</gl-link> </template> <template #branch> <strong>{{ pipeline.ref }}</strong> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue index efc60c9c037..787f21d9419 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue @@ -1,13 +1,13 @@ <script> -import { GlKeysetPagination } from '@gitlab/ui'; import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue'; import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; +import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; export default { components: { VersionRow, - GlKeysetPagination, PackagesListLoader, + RegistryList, }, props: { versions: { @@ -26,9 +26,6 @@ export default { }, }, computed: { - showPagination() { - return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage; - }, isListEmpty() { return this.versions.length === 0; }, @@ -42,16 +39,18 @@ export default { </div> <slot v-else-if="isListEmpty" name="empty-state"></slot> <div v-else> - <version-row v-for="version in versions" :key="version.id" :package-entity="version" /> - <div class="gl-display-flex gl-justify-content-center"> - <gl-keyset-pagination - v-if="showPagination" - v-bind="pageInfo" - class="gl-mt-3" - @prev="$emit('prev-page')" - @next="$emit('next-page')" - /> - </div> + <registry-list + :hidden-delete="true" + :is-loading="isLoading" + :items="versions" + :pagination="pageInfo" + @prev-page="$emit('prev-page')" + @next-page="$emit('next-page')" + > + <template #default="{ item }"> + <version-row :package-entity="item" /> + </template> + </registry-list> </div> </div> </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 index dd58f28a262..fdc6e75c932 100644 --- 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 @@ -30,7 +30,7 @@ export default { computed: { pypiPipCommand() { // eslint-disable-next-line @gitlab/require-i18n-strings - return `pip install ${this.packageEntity.name} --extra-index-url ${this.packageEntity.pypiUrl}`; + return `pip install ${this.packageEntity.name} --index-url ${this.packageEntity.pypiUrl}`; }, pypiSetupCommand() { return `[gitlab] diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue index ddcddf80c15..40bf7b7e143 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue @@ -5,10 +5,14 @@ import DeletePackageModal from '~/packages_and_registries/shared/components/dele import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue'; import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue'; import { DELETE_PACKAGE_TRACKING_ACTION, + DELETE_PACKAGES_TRACKING_ACTION, REQUEST_DELETE_PACKAGE_TRACKING_ACTION, + REQUEST_DELETE_PACKAGES_TRACKING_ACTION, CANCEL_DELETE_PACKAGE_TRACKING_ACTION, + CANCEL_DELETE_PACKAGES_TRACKING_ACTION, PACKAGE_ERROR_STATUS, } from '~/packages_and_registries/package_registry/constants'; import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils'; @@ -18,6 +22,7 @@ export default { name: 'PackagesList', components: { GlAlert, + DeleteModal, DeletePackageModal, PackagesListLoader, PackagesListRow, @@ -44,6 +49,7 @@ export default { data() { return { itemToBeDeleted: null, + itemsToBeDeleted: [], errorPackages: [], }; }, @@ -92,7 +98,18 @@ export default { this.setItemToBeDeleted(item); return; } - this.$emit('delete', items); + this.itemsToBeDeleted = items; + this.track(REQUEST_DELETE_PACKAGES_TRACKING_ACTION); + this.$refs.deletePackagesModal.show(); + }, + deleteItemsConfirmation() { + this.$emit('delete', this.itemsToBeDeleted); + this.track(DELETE_PACKAGES_TRACKING_ACTION); + this.itemsToBeDeleted = []; + }, + deleteItemsCanceled() { + this.track(CANCEL_DELETE_PACKAGES_TRACKING_ACTION); + this.itemsToBeDeleted = []; }, deleteItemConfirmation() { this.$emit('package:delete', this.itemToBeDeleted); @@ -159,6 +176,13 @@ export default { @ok="deleteItemConfirmation" @cancel="deleteItemCanceled" /> + + <delete-modal + ref="deletePackagesModal" + :items-to-be-deleted="itemsToBeDeleted" + @confirm="deleteItemsConfirmation" + @cancel="deleteItemsCanceled" + /> </template> </div> </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 index b731cd77e66..539b12bd6db 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js @@ -110,6 +110,11 @@ export const FETCH_PACKAGE_PIPELINES_ERROR_MESSAGE = s__( export const FETCH_PACKAGE_METADATA_ERROR_MESSAGE = s__( 'PackageRegistry|Something went wrong while fetching the package metadata.', ); + +export const DELETE_PACKAGES_TRACKING_ACTION = 'delete_packages'; +export const REQUEST_DELETE_PACKAGES_TRACKING_ACTION = 'request_delete_packages'; +export const CANCEL_DELETE_PACKAGES_TRACKING_ACTION = 'cancel_delete_packages'; + export const DELETE_PACKAGES_ERROR_MESSAGE = s__( 'PackageRegistry|Something went wrong while deleting packages.', ); @@ -184,9 +189,6 @@ export const PACKAGE_TYPES = [ s__('PackageRegistry|Helm'), ]; -export const HIDE_PACKAGE_MIGRATION_SURVEY_COOKIE = 'hide_package_registry_migration_survey'; -export const SURVEY_LINK = 'https://gitlab.fra1.qualtrics.com/jfe/form/SV_cHomH9FPzOaiDTU'; - // links export const EMPTY_LIST_HELP_URL = helpPagePath('user/packages/package_registry/index'); diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue index 8b5d51cb856..396429d60d8 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue @@ -1,20 +1,18 @@ <script> -import { GlAlert, GlBanner, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; -import { createAlert, VARIANT_INFO } from '~/flash'; -import { getCookie, historyReplaceState, parseBoolean, setCookie } from '~/lib/utils/common_utils'; +import { GlAlert, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import { createAlert, VARIANT_INFO, VARIANT_SUCCESS, VARIANT_DANGER } from '~/flash'; +import { historyReplaceState } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages_and_registries/shared/constants'; import { PROJECT_RESOURCE_TYPE, GROUP_RESOURCE_TYPE, GRAPHQL_PAGE_SIZE, - HIDE_PACKAGE_MIGRATION_SURVEY_COOKIE, DELETE_PACKAGE_SUCCESS_MESSAGE, DELETE_PACKAGES_ERROR_MESSAGE, DELETE_PACKAGES_SUCCESS_MESSAGE, EMPTY_LIST_HELP_URL, PACKAGE_HELP_URL, - SURVEY_LINK, } from '~/packages_and_registries/package_registry/constants'; import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql'; import destroyPackagesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_packages.mutation.graphql'; @@ -22,31 +20,26 @@ import DeletePackage from '~/packages_and_registries/package_registry/components import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue'; import PackageSearch from '~/packages_and_registries/package_registry/components/list/package_search.vue'; import PackageList from '~/packages_and_registries/package_registry/components/list/packages_list.vue'; -import DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue'; export default { components: { GlAlert, - GlBanner, GlEmptyState, GlLink, GlSprintf, PackageList, PackageTitle, PackageSearch, - DeleteModal, DeletePackage, }, inject: ['emptyListIllustration', 'isGroupPage', 'fullPath'], data() { return { alertVariables: null, - itemsToBeDeleted: [], packages: {}, sort: '', filters: {}, mutationLoading: false, - showSurveyBanner: !parseBoolean(getCookie(HIDE_PACKAGE_MIGRATION_SURVEY_COOKIE)), }; }, apollo: { @@ -121,15 +114,13 @@ export default { historyReplaceState(cleanUrl); } }, - async confirmDelete() { - const { itemsToBeDeleted } = this; - this.itemsToBeDeleted = []; + async deletePackages(packageEntities) { this.mutationLoading = true; try { const { data } = await this.$apollo.mutate({ mutation: destroyPackagesMutation, variables: { - ids: itemsToBeDeleted.map((i) => i.id), + ids: packageEntities.map((i) => i.id), }, awaitRefetchQueries: true, refetchQueries: [ @@ -144,30 +135,22 @@ export default { throw new Error(data.destroyPackages.errors[0]); } this.showAlert({ - variant: 'success', + variant: VARIANT_SUCCESS, message: DELETE_PACKAGES_SUCCESS_MESSAGE, }); } catch { this.showAlert({ - variant: 'danger', + variant: VARIANT_DANGER, message: DELETE_PACKAGES_ERROR_MESSAGE, }); } finally { this.mutationLoading = false; } }, - showDeletePackagesModal(toBeDeleted) { - this.itemsToBeDeleted = toBeDeleted; - this.$refs.deletePackagesModal.show(); - }, handleSearchUpdate({ sort, filters }) { this.sort = sort; this.filters = { ...filters }; }, - hideSurvey() { - this.showSurveyBanner = false; - setCookie(HIDE_PACKAGE_MIGRATION_SURVEY_COOKIE, 'true'); - }, updateQuery(_, { fetchMoreResult }) { return fetchMoreResult; }, @@ -208,17 +191,11 @@ export default { noResultsText: s__( 'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.', ), - surveyBannerTitle: s__('PackageRegistry|Help us learn about your registry migration needs'), - surveyBannerDescription: s__( - 'PackageRegistry|If you are interested in migrating packages from your private registry to the GitLab Package Registry, take our survey and tell us more about your needs.', - ), - surveyBannerPrimaryButtonText: s__('PackageRegistry|Take survey'), }, links: { EMPTY_LIST_HELP_URL, PACKAGE_HELP_URL, }, - surveyLink: SURVEY_LINK, }; </script> @@ -233,17 +210,6 @@ export default { > {{ alertVariables.message }} </gl-alert> - <gl-banner - v-if="showSurveyBanner" - :title="$options.i18n.surveyBannerTitle" - :button-text="$options.i18n.surveyBannerPrimaryButtonText" - :button-link="$options.surveyLink" - class="gl-mt-3" - @primary="hideSurvey" - @close="hideSurvey" - > - <p>{{ $options.i18n.surveyBannerDescription }}</p> - </gl-banner> <package-title :help-url="$options.links.PACKAGE_HELP_URL" :count="packagesCount" /> <package-search class="gl-mb-5" @update="handleSearchUpdate" /> @@ -261,7 +227,7 @@ export default { @prev-page="fetchPreviousPage" @next-page="fetchNextPage" @package:delete="deletePackage" - @delete="showDeletePackagesModal" + @delete="deletePackages" > <template #empty-state> <gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration"> @@ -280,11 +246,5 @@ export default { </package-list> </template> </delete-package> - - <delete-modal - ref="deletePackagesModal" - :items-to-be-deleted="itemsToBeDeleted" - @confirm="confirmDelete" - /> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue index cc345fda7e8..d07d0a7673f 100644 --- a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue @@ -87,13 +87,15 @@ export default { <template> <div> - <div class="gl-display-flex gl-justify-content-space-between gl-mb-3 gl-align-items-center"> - <gl-form-checkbox v-if="!hiddenDelete" v-model="selectAll" class="gl-ml-2 gl-pt-2"> + <div + v-if="!hiddenDelete" + class="gl-display-flex gl-justify-content-space-between gl-mb-3 gl-align-items-center" + > + <gl-form-checkbox v-model="selectAll" class="gl-ml-2 gl-pt-2"> <span class="gl-font-weight-bold">{{ title }}</span> </gl-form-checkbox> <gl-button - v-if="!hiddenDelete" :disabled="disableDeleteButton" category="secondary" variant="danger" diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js index a6e3a7dc08a..f1e92cf195a 100644 --- a/app/assets/javascripts/pages/admin/application_settings/index.js +++ b/app/assets/javascripts/pages/admin/application_settings/index.js @@ -1,4 +1,4 @@ -import initVariableList from '~/ci_variable_list'; +import initVariableList from '~/ci/ci_variable_list'; import projectSelect from '~/project_select'; import initSearchSettings from '~/search_settings'; import selfMonitor from '~/self_monitor'; diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js deleted file mode 100644 index 40348e0b18a..00000000000 --- a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js +++ /dev/null @@ -1,60 +0,0 @@ -import $ from 'jquery'; -import { debounce } from 'lodash'; -import { createAlert } from '~/flash'; -import axios from '~/lib/utils/axios_utils'; -import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { __ } from '~/locale'; - -export default () => { - const $broadcastMessageTheme = $('.js-broadcast-message-theme'); - const $broadcastMessageType = $('.js-broadcast-message-type'); - const $broadcastBannerMessagePreview = $('.js-broadcast-banner-message-preview [role="alert"]'); - const $broadcastMessage = $('.js-broadcast-message-message'); - const $jsBroadcastMessagePreview = $('#broadcast-message-preview'); - - const reloadPreview = function reloadPreview() { - const previewPath = $broadcastMessage.data('previewPath'); - const message = $broadcastMessage.val(); - const type = $broadcastMessageType.val(); - const theme = $broadcastMessageTheme.val(); - - axios - .post(previewPath, { - broadcast_message: { - message, - broadcast_type: type, - theme, - }, - }) - .then(({ data }) => { - $jsBroadcastMessagePreview.html(data); - }) - .catch(() => - createAlert({ - message: __('An error occurred while rendering preview broadcast message'), - }), - ); - }; - - $broadcastMessageTheme.on('change', reloadPreview); - - $broadcastMessageType.on('change', () => { - const $broadcastMessageColorFormGroup = $('.js-broadcast-message-background-color-form-group'); - const $broadcastMessageDismissableFormGroup = $('.js-broadcast-message-dismissable-form-group'); - const $broadcastNotificationMessagePreview = $('.js-broadcast-notification-message-preview'); - - $broadcastMessageColorFormGroup.toggleClass('hidden'); - $broadcastMessageDismissableFormGroup.toggleClass('hidden'); - $broadcastBannerMessagePreview.toggleClass('hidden'); - $broadcastNotificationMessagePreview.toggleClass('hidden'); - - reloadPreview(); - }); - - $broadcastMessage.on( - 'input', - debounce(() => { - reloadPreview(); - }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), - ); -}; diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/edit/index.js b/app/assets/javascripts/pages/admin/broadcast_messages/edit/index.js index 25036984082..94cae500a1e 100644 --- a/app/assets/javascripts/pages/admin/broadcast_messages/edit/index.js +++ b/app/assets/javascripts/pages/admin/broadcast_messages/edit/index.js @@ -1,8 +1,3 @@ import initEditBroadcastMessage from '~/admin/broadcast_messages/edit'; -import initBroadcastMessagesForm from '../broadcast_message'; -if (gon.features.vueBroadcastMessages) { - initEditBroadcastMessage(); -} else { - initBroadcastMessagesForm(); -} +initEditBroadcastMessage(); diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/index/index.js b/app/assets/javascripts/pages/admin/broadcast_messages/index/index.js index 1f37df2b340..2662496be05 100644 --- a/app/assets/javascripts/pages/admin/broadcast_messages/index/index.js +++ b/app/assets/javascripts/pages/admin/broadcast_messages/index/index.js @@ -1,10 +1,3 @@ import initBroadcastMessages from '~/admin/broadcast_messages'; -import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior'; -import initBroadcastMessagesForm from '../broadcast_message'; -if (gon.features.vueBroadcastMessages) { - initBroadcastMessages(); -} else { - initBroadcastMessagesForm(); - initDeprecatedRemoveRowBehavior(); -} +initBroadcastMessages(); diff --git a/app/assets/javascripts/pages/groups/boards/index.js b/app/assets/javascripts/pages/groups/boards/index.js index bde0007ec6a..23f5b083589 100644 --- a/app/assets/javascripts/pages/groups/boards/index.js +++ b/app/assets/javascripts/pages/groups/boards/index.js @@ -1,7 +1,5 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import initBoards from '~/boards'; -import UsersSelect from '~/users_select'; -new UsersSelect(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new initBoards(); diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js index 58ca195d7b9..fb685247bd4 100644 --- a/app/assets/javascripts/pages/groups/edit/index.js +++ b/app/assets/javascripts/pages/groups/edit/index.js @@ -2,7 +2,7 @@ import { GROUP_BADGE } from '~/badges/constants'; import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; import initFilePickers from '~/file_pickers'; import initTransferGroupForm from '~/groups/init_transfer_group_form'; -import groupsSelect from '~/groups_select'; +import { initGroupSelects } from '~/vue_shared/components/group_select/init_group_selects'; import { initCascadingSettingsLockPopovers } from '~/namespaces/cascading_settings'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; import projectSelect from '~/project_select'; @@ -20,7 +20,7 @@ dirtySubmitFactory( mountBadgeSettings(GROUP_BADGE); // Initialize Subgroups selector -groupsSelect(); +initGroupSelects(); projectSelect(); diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index 62d47cb49b8..ceda2c8fa17 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -11,7 +11,7 @@ import { groupLinkRequestFormatter } from '~/members/utils'; const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions']; const APP_OPTIONS = { [MEMBER_TYPES.user]: { - tableFields: SHARED_FIELDS.concat(['source', 'granted', 'userCreatedAt', 'lastActivityOn']), + tableFields: SHARED_FIELDS.concat(['source', 'activity']), tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, tableSortableFields: [ 'account', diff --git a/app/assets/javascripts/pages/groups/observability/datasources/index.js b/app/assets/javascripts/pages/groups/observability/datasources/index.js new file mode 100644 index 00000000000..c3b6ce6f99f --- /dev/null +++ b/app/assets/javascripts/pages/groups/observability/datasources/index.js @@ -0,0 +1,3 @@ +import ObservabilityApp from '~/observability'; + +ObservabilityApp(); diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js index b1a1cc21764..184958bd189 100644 --- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js @@ -1,5 +1,5 @@ import initStaleRunnerCleanupSetting from 'ee_else_ce/group_settings/stale_runner_cleanup'; -import initVariableList from '~/ci_variable_list'; +import initVariableList from '~/ci/ci_variable_list'; import initSharedRunnersForm from '~/group_settings/mount_shared_runners'; import initSettingsPanels from '~/settings_panels'; import initDeployTokens from '~/deploy_tokens'; diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js index 161fca83a58..53bceb3a6f0 100644 --- a/app/assets/javascripts/pages/groups/show/index.js +++ b/app/assets/javascripts/pages/groups/show/index.js @@ -1,7 +1,9 @@ import leaveByUrl from '~/namespaces/leave_by_url'; import { initGroupOverviewTabs } from '~/groups/init_overview_tabs'; +import initReadMore from '~/read_more'; import initGroupDetails from '../shared/group_details'; leaveByUrl('group'); initGroupDetails(); initGroupOverviewTabs(); +initReadMore(); diff --git a/app/assets/javascripts/pages/import/bitbucket/status/index.js b/app/assets/javascripts/pages/import/bitbucket/status/index.js index 6e9c26bf930..0b0f222ab76 100644 --- a/app/assets/javascripts/pages/import/bitbucket/status/index.js +++ b/app/assets/javascripts/pages/import/bitbucket/status/index.js @@ -1,21 +1,6 @@ -import Vue from 'vue'; -import { initStoreFromElement, initPropsFromElement } from '~/import_entities/import_projects'; +import mountImportProjectsTable from '~/import_entities/import_projects'; import BitbucketStatusTable from '~/import_entities/import_projects/components/bitbucket_status_table.vue'; -function importBitBucket() { - const mountElement = document.getElementById('import-projects-mount-element'); - if (!mountElement) return undefined; +const mountElement = document.getElementById('import-projects-mount-element'); - const store = initStoreFromElement(mountElement); - const attrs = initPropsFromElement(mountElement); - - return new Vue({ - el: mountElement, - store, - render(createElement) { - return createElement(BitbucketStatusTable, { attrs }); - }, - }); -} - -importBitBucket(); +mountImportProjectsTable({ mountElement, Component: BitbucketStatusTable }); 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 90eb423c7a7..680ff0ddcde 100644 --- a/app/assets/javascripts/pages/import/bitbucket_server/status/index.js +++ b/app/assets/javascripts/pages/import/bitbucket_server/status/index.js @@ -1,24 +1,10 @@ -import Vue from 'vue'; -import { initStoreFromElement, initPropsFromElement } from '~/import_entities/import_projects'; -import BitbucketServerStatusTable from './components/bitbucket_server_status_table.vue'; - -function BitbucketServerStatus() { - const mountElement = document.getElementById('import-projects-mount-element'); - if (!mountElement) return undefined; +import mountImportProjectsTable from '~/import_entities/import_projects'; - const store = initStoreFromElement(mountElement); - const attrs = initPropsFromElement(mountElement); - const { reconfigurePath } = mountElement.dataset; - - return new Vue({ - el: mountElement, - store, - render(createElement) { - return createElement(BitbucketServerStatusTable, { - attrs: { ...attrs, reconfigurePath }, - }); - }, - }); -} +import BitbucketServerStatusTable from './components/bitbucket_server_status_table.vue'; -BitbucketServerStatus(); +const mountElement = document.getElementById('import-projects-mount-element'); +mountImportProjectsTable({ + mountElement, + Component: BitbucketServerStatusTable, + extraProps: ({ reconfigurePath }) => ({ reconfigurePath }), +}); diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue index 6feb4c2188f..3dcababb4fd 100644 --- a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue +++ b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue @@ -1,5 +1,13 @@ <script> -import { GlButton, GlEmptyState, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { + GlButton, + GlEmptyState, + GlIcon, + GlLink, + GlLoadingIcon, + GlTableLite, + GlTooltipDirective as GlTooltip, +} from '@gitlab/ui'; import { s__, __ } from '~/locale'; import { createAlert } from '~/flash'; @@ -34,15 +42,20 @@ export default { components: { GlButton, GlEmptyState, + GlIcon, GlLink, GlLoadingIcon, - GlTable, + GlTableLite, PaginationBar, ImportStatus, TimeAgo, LocalStorageSync, }, + directives: { + GlTooltip, + }, + data() { return { loading: true, @@ -58,12 +71,12 @@ export default { fields: [ tableCell({ key: 'source_full_path', - label: s__('BulkImport|Source group'), + label: s__('BulkImport|Source'), thClass: `${DEFAULT_TH_CLASSES} gl-w-30p`, }), tableCell({ key: 'destination_name', - label: s__('BulkImport|Destination group'), + label: s__('BulkImport|Destination'), thClass: `${DEFAULT_TH_CLASSES} gl-w-40p`, }), tableCell({ @@ -113,12 +126,24 @@ export default { } }, - getDestinationUrl({ destination_name: name, destination_namespace: namespace }) { - return [namespace, name].filter(Boolean).join('/'); + getFullDestinationUrl(params) { + return joinPaths(gon.relative_url_root || '', '/', params.destination_full_path); }, - getFullDestinationUrl(params) { - return joinPaths(gon.relative_url_root || '', '/', this.getDestinationUrl(params)); + getPresentationUrl(item) { + const suffix = item.entity_type === 'group' ? '/' : ''; + return `${item.destination_full_path}${suffix}`; + }, + + getEntityTooltip(item) { + switch (item.entity_type) { + case 'project': + return __('Project'); + case 'group': + return __('Group'); + default: + return ''; + } }, }, @@ -134,26 +159,36 @@ export default { > <h1 class="gl-my-0 gl-py-4 gl-font-size-h1"> <img :src="$options.gitlabLogo" class="gl-w-6 gl-h-6 gl-mb-2 gl-display-inline gl-mr-2" /> - {{ s__('BulkImport|Group import history') }} + {{ s__('BulkImport|GitLab Migration history') }} </h1> </div> <gl-loading-icon v-if="loading" size="lg" class="gl-mt-5" /> <gl-empty-state v-else-if="!hasHistoryItems" :title="s__('BulkImport|No history is available')" - :description="s__('BulkImport|Your imported groups will appear here.')" + :description="s__('BulkImport|Your imported groups and projects will appear here.')" /> <template v-else> - <gl-table + <gl-table-lite :fields="$options.fields" :items="historyItems" data-qa-selector="import_history_table" class="gl-w-full" > <template #cell(destination_name)="{ item }"> - <gl-link :href="getFullDestinationUrl(item)" target="_blank"> - {{ getDestinationUrl(item) }} - </gl-link> + <template v-if="item.destination_full_path"> + <gl-icon + v-gl-tooltip + :name="item.entity_type" + :title="getEntityTooltip(item)" + :aria-label="getEntityTooltip(item)" + class="gl-text-gray-500" + /> + <gl-link :href="getFullDestinationUrl(item)" target="_blank"> + {{ getPresentationUrl(item) }} + </gl-link> + </template> + <gl-loading-icon v-else inline /> </template> <template #cell(created_at)="{ value }"> <time-ago :time="value" /> @@ -171,7 +206,7 @@ export default { <template #row-details="{ item }"> <pre><code>{{ item.failures }}</code></pre> </template> - </gl-table> + </gl-table-lite> <pagination-bar :page-info="pageInfo" class="gl-m-0 gl-mt-3" diff --git a/app/assets/javascripts/pages/import/fogbugz/status/index.js b/app/assets/javascripts/pages/import/fogbugz/status/index.js index 4c427b72372..30ee468734d 100644 --- a/app/assets/javascripts/pages/import/fogbugz/status/index.js +++ b/app/assets/javascripts/pages/import/fogbugz/status/index.js @@ -2,4 +2,4 @@ import mountImportProjectsTable from '~/import_entities/import_projects'; 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 4c427b72372..30ee468734d 100644 --- a/app/assets/javascripts/pages/import/gitea/status/index.js +++ b/app/assets/javascripts/pages/import/gitea/status/index.js @@ -2,4 +2,4 @@ import mountImportProjectsTable from '~/import_entities/import_projects'; 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 4c427b72372..30ee468734d 100644 --- a/app/assets/javascripts/pages/import/github/status/index.js +++ b/app/assets/javascripts/pages/import/github/status/index.js @@ -2,4 +2,4 @@ import mountImportProjectsTable from '~/import_entities/import_projects'; 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 4c427b72372..30ee468734d 100644 --- a/app/assets/javascripts/pages/import/gitlab/status/index.js +++ b/app/assets/javascripts/pages/import/gitlab/status/index.js @@ -2,4 +2,4 @@ import mountImportProjectsTable from '~/import_entities/import_projects'; const mountElement = document.getElementById('import-projects-mount-element'); -mountImportProjectsTable(mountElement); +mountImportProjectsTable({ mountElement }); diff --git a/app/assets/javascripts/pages/import/manifest/status/index.js b/app/assets/javascripts/pages/import/manifest/status/index.js index 4c427b72372..30ee468734d 100644 --- a/app/assets/javascripts/pages/import/manifest/status/index.js +++ b/app/assets/javascripts/pages/import/manifest/status/index.js @@ -2,4 +2,4 @@ import mountImportProjectsTable from '~/import_entities/import_projects'; const mountElement = document.getElementById('import-projects-mount-element'); -mountImportProjectsTable(mountElement); +mountImportProjectsTable({ mountElement }); diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index af0097b415c..46704d96552 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -78,3 +78,5 @@ if (filesContainer.length) { loadAwardsHandler(); initCommitActions(); + +syntaxHighlight([document.querySelector('.files')]); diff --git a/app/assets/javascripts/pages/projects/compare/show/index.js b/app/assets/javascripts/pages/projects/compare/show/index.js index b74f7d1cf57..760bf3f7131 100644 --- a/app/assets/javascripts/pages/projects/compare/show/index.js +++ b/app/assets/javascripts/pages/projects/compare/show/index.js @@ -2,6 +2,7 @@ import Diff from '~/diff'; import GpgBadges from '~/gpg_badges'; import { initDiffStatsDropdown } from '~/init_diff_stats_dropdown'; import initCompareSelector from '~/projects/compare'; +import syntaxHighlight from '~/syntax_highlight'; initCompareSelector(); @@ -9,3 +10,5 @@ new Diff(); // eslint-disable-line no-new const paddingTop = 16; initDiffStatsDropdown(document.querySelector('.navbar-gitlab').offsetHeight - paddingTop); GpgBadges.fetch(); + +syntaxHighlight([document.querySelector('.files')]); diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue index 91650003d4a..2028af8b8f0 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue @@ -57,6 +57,9 @@ export default { visibilityHelpPath: { default: '', }, + cancelPath: { + default: '', + }, projectFullPath: { default: '', }, @@ -97,7 +100,7 @@ export default { required: false, skipValidation: true, }), - visibility: initFormField({ value: this.getInitialVisibilityValue() }), + visibility: initFormField({ value: null }), }, }; return { @@ -106,8 +109,39 @@ export default { }; }, computed: { + projectVisibilityLevel() { + return VISIBILITY_LEVELS_STRING_TO_INTEGER[this.projectVisibility]; + }, + namespaceVisibilityLevel() { + const visibility = + this.form.fields.namespace.value?.visibility || VISIBILITY_LEVEL_PUBLIC_STRING; + return VISIBILITY_LEVELS_STRING_TO_INTEGER[visibility]; + }, + visibilityLevelCap() { + return Math.min(this.projectVisibilityLevel, this.namespaceVisibilityLevel); + }, + restrictedVisibilityLevelsSet() { + return new Set(this.restrictedVisibilityLevels); + }, allowedVisibilityLevels() { - return this.getAllowedVisibilityLevels(); + const allowedLevels = Object.entries(VISIBILITY_LEVELS_STRING_TO_INTEGER).reduce( + (levels, [levelName, levelValue]) => { + if ( + !this.restrictedVisibilityLevelsSet.has(levelValue) && + levelValue <= this.visibilityLevelCap + ) { + levels.push(levelName); + } + return levels; + }, + [], + ); + + if (!allowedLevels.length) { + return [VISIBILITY_LEVEL_PRIVATE_STRING]; + } + + return allowedLevels; }, visibilityLevels() { return [ @@ -143,13 +177,15 @@ export default { this.form.fields.slug.value = kebabCase(newVal); }, }, + created() { + this.form.fields.visibility.value = this.getMaximumAllowedVisibilityLevel( + VISIBILITY_LEVEL_PUBLIC_STRING, + ); + }, methods: { isVisibilityLevelDisabled(visibility) { return !this.allowedVisibilityLevels.includes(visibility); }, - getInitialVisibilityValue() { - return this.getMaximumAllowedVisibilityLevel(this.projectVisibility); - }, setNamespace(namespace) { this.form.fields.namespace.value = namespace; this.form.fields.namespace.state = true; @@ -157,42 +193,8 @@ export default { this.form.fields.visibility.value, ); }, - getProjectVisibilityLevel() { - return VISIBILITY_LEVELS_STRING_TO_INTEGER[this.projectVisibility]; - }, - getNamespaceVisibilityLevel() { - const visibility = - this.form?.fields?.namespace?.value?.visibility || VISIBILITY_LEVEL_PUBLIC_STRING; - return VISIBILITY_LEVELS_STRING_TO_INTEGER[visibility]; - }, - getVisibilityLevelCap() { - return Math.min(this.getProjectVisibilityLevel(), this.getNamespaceVisibilityLevel()); - }, - getRestrictedVisibilityLevelsSet() { - return new Set(this.restrictedVisibilityLevels); - }, - getAllowedVisibilityLevels() { - const allowedLevels = Object.entries(VISIBILITY_LEVELS_STRING_TO_INTEGER).reduce( - (levels, [levelName, levelValue]) => { - if ( - !this.getRestrictedVisibilityLevelsSet().has(levelValue) && - levelValue <= this.getVisibilityLevelCap() - ) { - levels.push(levelName); - } - return levels; - }, - [], - ); - - if (!allowedLevels.length) { - return [VISIBILITY_LEVEL_PRIVATE_STRING]; - } - - return allowedLevels; - }, getMaximumAllowedVisibilityLevel(visibility) { - const allowedVisibilities = this.getAllowedVisibilityLevels().map( + const allowedVisibilities = this.allowedVisibilityLevels.map( (s) => VISIBILITY_LEVELS_STRING_TO_INTEGER[s], ); const current = VISIBILITY_LEVELS_STRING_TO_INTEGER[visibility]; @@ -373,7 +375,7 @@ export default { class="gl-mr-3" data-testid="cancel-button" :disabled="isSaving" - :href="projectFullPath" + :href="cancelPath" > {{ s__('ForkProject|Cancel') }} </gl-button> diff --git a/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue index 00e0649deed..5e0c5735bc0 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue @@ -1,14 +1,5 @@ <script> -import { - GlButton, - GlButtonGroup, - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlDropdownSectionHeader, - GlSearchBoxByType, - GlTruncate, -} from '@gitlab/ui'; +import { GlButton, GlButtonGroup, GlCollapsibleListbox } from '@gitlab/ui'; import { createAlert } from '~/flash'; import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants'; import { s__ } from '~/locale'; @@ -20,12 +11,7 @@ export default { components: { GlButton, GlButtonGroup, - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlDropdownSectionHeader, - GlSearchBoxByType, - GlTruncate, + GlCollapsibleListbox, }, apollo: { project: { @@ -61,24 +47,25 @@ export default { }; }, computed: { + loading() { + return this.$apollo.queries.project.loading; + }, rootUrl() { return `${gon.gitlab_url}/`; }, namespaces() { return this.project.forkTargets?.nodes || []; }, - hasMatches() { - return this.namespaces.length; - }, dropdownText() { return this.selectedNamespace?.fullPath || s__('ForkProject|Select a namespace'); }, + namespaceItems() { + return this.namespaces?.map(({ id, fullPath }) => ({ value: id, text: fullPath })); + }, }, methods: { - handleDropdownShown() { - this.$refs.search.focusInput(); - }, - setNamespace(namespace) { + setNamespace(namespaceId) { + const namespace = this.namespaces.find(({ id }) => id === namespaceId); const id = getIdFromGraphQLId(namespace.id); this.$emit('select', { @@ -89,6 +76,9 @@ export default { this.selectedNamespace = { id, fullPath: namespace.fullPath }; }, + searchNamespaces(search) { + this.search = search; + }, }, }; </script> @@ -98,39 +88,19 @@ export default { <gl-button class="gl-text-truncate gl-flex-grow-0! gl-max-w-34" label :title="rootUrl">{{ rootUrl }}</gl-button> - - <gl-dropdown + <gl-collapsible-listbox class="gl-flex-grow-1" - toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20" data-qa-selector="select_namespace_dropdown" data-testid="select_namespace_dropdown" - no-flip - @shown="handleDropdownShown" - > - <template #button-text> - <gl-truncate :text="dropdownText" position="start" with-tooltip /> - </template> - <gl-search-box-by-type - ref="search" - v-model.trim="search" - :is-loading="$apollo.queries.project.loading" - data-qa-selector="select_namespace_dropdown_search_field" - data-testid="select_namespace_dropdown_search_field" - /> - <template v-if="!$apollo.queries.project.loading"> - <template v-if="hasMatches"> - <gl-dropdown-section-header>{{ __('Namespaces') }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="namespace of namespaces" - :key="namespace.id" - data-qa-selector="select_namespace_dropdown_item" - @click="setNamespace(namespace)" - > - {{ namespace.fullPath }} - </gl-dropdown-item> - </template> - <gl-dropdown-text v-else>{{ __('No matches found') }}</gl-dropdown-text> - </template> - </gl-dropdown> + :items="namespaceItems" + :header-text="__('Namespaces')" + :no-results-text="__('No matches found')" + :searchable="true" + :searching="loading" + toggle-class="gl-flex-direction-column gl-align-items-stretch!" + :toggle-text="dropdownText" + @search="searchNamespaces" + @select="setNamespace" + /> </gl-button-group> </template> diff --git a/app/assets/javascripts/pages/projects/forks/new/index.js b/app/assets/javascripts/pages/projects/forks/new/index.js index d3a5ce5390f..a31b8b1a1f4 100644 --- a/app/assets/javascripts/pages/projects/forks/new/index.js +++ b/app/assets/javascripts/pages/projects/forks/new/index.js @@ -11,6 +11,7 @@ const { newGroupPath, projectFullPath, visibilityHelpPath, + cancelPath, projectId, projectName, projectPath, @@ -30,6 +31,7 @@ new Vue({ provide: { newGroupPath, visibilityHelpPath, + cancelPath, endpoint, projectFullPath, projectId, diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index 7380055cbbf..37cf345fe77 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -1,9 +1,7 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import initTerraformNotification from '~/projects/terraform_notification'; -import { initSidebarTracking } from '../shared/nav/sidebar_tracking'; import Project from './project'; new Project(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new -initSidebarTracking(); initTerraformNotification(); diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue index 3717d8027c4..d9b0dbbb9b0 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue @@ -22,7 +22,7 @@ export default { GlTooltip, }, i18n: { - contactAdmin: s__('LearnGitlab|Contact your administrator to start a free Ultimate trial.'), + contactAdmin: s__('LearnGitlab|Contact your administrator to enable this action.'), viewAdminList: s__('LearnGitlab|View administrator list'), watchHow: __('Watch how'), }, @@ -50,6 +50,9 @@ export default { openInNewTab() { return ACTION_LABELS[this.action]?.openInNewTab === true || this.value.openInNewTab === true; }, + popoverText() { + return this.value.message || this.$options.i18n.contactAdmin; + }, }, methods: { openModal() { @@ -101,7 +104,7 @@ export default { category="tertiary" icon="question-o" class="ml-auto" - :aria-label="$options.i18n.contactAdmin" + :aria-label="popoverText" size="small" data-testid="contact-admin-popover-trigger" /> @@ -111,7 +114,7 @@ export default { triggers="hover focus" data-testid="contact-admin-popover" > - <p>{{ $options.i18n.contactAdmin }}</p> + <p>{{ popoverText }}</p> <gl-link :href="value.url" class="font-size-inherit" diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js index a4e3ddfc506..d4734b8842d 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js @@ -4,14 +4,12 @@ import $ from 'jquery'; import IssuableForm from 'ee_else_ce/issuable/issuable_form'; import IssuableLabelSelector from '~/issuable/issuable_label_selector'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -import Diff from '~/diff'; import GLForm from '~/gl_form'; import LabelsSelect from '~/labels/labels_select'; import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; import { mountMilestoneDropdown } from '~/sidebar/mount_sidebar'; export default () => { - new Diff(); new ShortcutsNavigation(); new GLForm($('.merge-request-form')); new IssuableForm($('.merge-request-form')); diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js index 568bf19b55e..f0a955e5360 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js @@ -1,5 +1,7 @@ import initNotesApp from '~/mr_notes/init_notes'; +import { initReportAbuse } from '~/projects/merge_requests'; import { initMrPage } from '../page'; initMrPage(); initNotesApp(); +initReportAbuse(); diff --git a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js index 97e436920c7..6947b15dcbe 100644 --- a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js +++ b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import MlExperiment from '~/ml/experiment_tracking/components/ml_experiment.vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; const initShowExperiment = () => { const element = document.querySelector('#js-show-ml-experiment'); @@ -13,6 +14,7 @@ const initShowExperiment = () => { const candidates = JSON.parse(element.dataset.candidates); const metricNames = JSON.parse(element.dataset.metrics); const paramNames = JSON.parse(element.dataset.params); + const pagination = convertObjectPropsToCamelCase(JSON.parse(element.dataset.pagination)); // eslint-disable-next-line no-new new Vue({ @@ -21,6 +23,7 @@ const initShowExperiment = () => { candidates, metricNames, paramNames, + pagination, }, render(h) { return h(MlExperiment); 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 27610df482d..4bdbb70d942 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js @@ -67,10 +67,9 @@ function initTakeownershipModal() { }); } -initPipelineSchedulesCallout(); - if (gon.features?.pipelineSchedulesVue) { initPipelineSchedulesApp(); } else { + initPipelineSchedulesCallout(); initTakeownershipModal(); } diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue index fd8b1a6290f..242c5a1a97b 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue @@ -54,7 +54,7 @@ export default { inputNameAttribute: 'schedule[cron]', radioValue: this.initialCronInterval ? KEY_CUSTOM : KEY_EVERY_DAY, cronInterval: this.initialCronInterval, - cronSyntaxUrl: 'https://en.wikipedia.org/wiki/Cron', + cronSyntaxUrl: 'https://docs.gitlab.com/ee/topics/cron/', }; }, computed: { @@ -95,7 +95,7 @@ export default { }, { value: KEY_CUSTOM, - text: s__('PipelineScheduleIntervalPattern|Custom (%{linkStart}Cron syntax%{linkEnd})'), + text: s__('PipelineScheduleIntervalPattern|Custom (%{linkStart}Learn more.%{linkEnd})'), link: this.cronSyntaxUrl, }, ]; diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js index abd17efc498..8440d0e77cd 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js @@ -3,7 +3,7 @@ import Vue from 'vue'; import { __ } from '~/locale'; import RefSelector from '~/ref/components/ref_selector.vue'; import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; -import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list'; +import setupNativeFormVariableList from '~/ci/ci_variable_list/native_form_variable_list'; import GlFieldErrors from '~/gl_field_errors'; import Translate from '~/vue_shared/translate'; import { initTimezoneDropdown } from '../../../profiles/init_timezone_dropdown'; diff --git a/app/assets/javascripts/pages/projects/pipelines/new/index.js b/app/assets/javascripts/pages/projects/pipelines/new/index.js index e1f71965853..9b94b8ba96b 100644 --- a/app/assets/javascripts/pages/projects/pipelines/new/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/new/index.js @@ -1,3 +1,3 @@ -import initNewPipelineForm from '~/pipeline_new/index'; +import initNewPipelineForm from '~/ci/pipeline_new/index'; initNewPipelineForm(); diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js index 9a7fd74fd8c..2fd372a45b8 100644 --- a/app/assets/javascripts/pages/projects/project_members/index.js +++ b/app/assets/javascripts/pages/projects/project_members/index.js @@ -20,7 +20,7 @@ initImportProjectMembersTrigger(); const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions']; initMembersApp(document.querySelector('.js-project-members-list-app'), { [MEMBER_TYPES.user]: { - tableFields: SHARED_FIELDS.concat(['source', 'granted', 'userCreatedAt', 'lastActivityOn']), + tableFields: SHARED_FIELDS.concat(['source', 'activity']), tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, tableSortableFields: [ 'account', diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index 8909ff1f221..895c7d0a18e 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -1,7 +1,7 @@ import initArtifactsSettings from '~/artifacts_settings'; import SecretValues from '~/behaviors/secret_values'; import initSettingsPipelinesTriggers from '~/ci_settings_pipeline_triggers'; -import initVariableList from '~/ci_variable_list'; +import initVariableList from '~/ci/ci_variable_list'; import initDeployFreeze from '~/deploy_freeze'; import registrySettingsApp from '~/packages_and_registries/settings/project/registry_settings_bundle'; import { initRunnerAwsDeployments } from '~/pages/shared/mount_runner_aws_deployments'; @@ -11,6 +11,7 @@ import initSettingsPanels from '~/settings_panels'; import { initTokenAccess } from '~/token_access'; import { initCiSecureFiles } from '~/ci_secure_files'; import initDeployTokens from '~/deploy_tokens'; +import { initProjectRunners } from '~/ci/runner/project_runners'; // Initialize expandable settings panels initSettingsPanels(); @@ -37,11 +38,13 @@ document.querySelector('.js-toggle-extra-settings').addEventListener('click', (e registrySettingsApp(); initDeployTokens(); initDeployFreeze(); - initSettingsPipelinesTriggers(); initArtifactsSettings(); + +initProjectRunners(); initSharedRunnersToggle(); initInstallRunner(); initRunnerAwsDeployments(); + initTokenAccess(); initCiSecureFiles(); diff --git a/app/assets/javascripts/pages/projects/settings/merge_requests/index.js b/app/assets/javascripts/pages/projects/settings/merge_requests/index.js index 0f7ede8ed42..40741be5f53 100644 --- a/app/assets/javascripts/pages/projects/settings/merge_requests/index.js +++ b/app/assets/javascripts/pages/projects/settings/merge_requests/index.js @@ -1,7 +1,4 @@ -import groupsSelect from '~/groups_select'; import UserCallout from '~/user_callout'; -groupsSelect(); - // eslint-disable-next-line no-new new UserCallout({ className: 'js-mr-approval-callout' }); diff --git a/app/assets/javascripts/pages/projects/usage_quotas/index.js b/app/assets/javascripts/pages/projects/usage_quotas/index.js new file mode 100644 index 00000000000..885b8ca8e12 --- /dev/null +++ b/app/assets/javascripts/pages/projects/usage_quotas/index.js @@ -0,0 +1,9 @@ +import initProjectStorage from '~/usage_quotas/storage/init_project_storage'; +import initSearchSettings from '~/search_settings'; + +const initVueApp = () => { + initProjectStorage('js-project-storage-count-app'); +}; + +initVueApp(); +initSearchSettings(); diff --git a/app/assets/javascripts/pages/users/index.js b/app/assets/javascripts/pages/users/index.js index 5cbb7a06bc1..30c351359e4 100644 --- a/app/assets/javascripts/pages/users/index.js +++ b/app/assets/javascripts/pages/users/index.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import { setCookie } from '~/lib/utils/common_utils'; import UserCallout from '~/user_callout'; +import { initReportAbuse } from '~/users/profile'; import UserTabs from './user_tabs'; function initUserProfile(action) { @@ -19,3 +20,4 @@ const page = $('body').attr('data-page'); const action = page.split(':')[1]; initUserProfile(action); new UserCallout(); // eslint-disable-line no-new +initReportAbuse(); diff --git a/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue b/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue deleted file mode 100644 index d35d2010150..00000000000 --- a/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue +++ /dev/null @@ -1,114 +0,0 @@ -<script> -import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui'; -import { debounce } from 'lodash'; -import axios from '~/lib/utils/axios_utils'; -import { BRANCH_REF_TYPE, TAG_REF_TYPE, DEBOUNCE_REFS_SEARCH_MS } from '../constants'; -import formatRefs from '../utils/format_refs'; - -export default { - components: { - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, - GlSearchBoxByType, - }, - inject: ['projectRefsEndpoint'], - props: { - value: { - type: Object, - required: false, - default: () => ({}), - }, - }, - data() { - return { - isLoading: false, - searchTerm: '', - branches: [], - tags: [], - }; - }, - computed: { - lowerCasedSearchTerm() { - return this.searchTerm.toLowerCase(); - }, - refShortName() { - return this.value.shortName; - }, - hasTags() { - return this.tags.length > 0; - }, - }, - watch: { - searchTerm() { - this.debouncedLoadRefs(); - }, - }, - methods: { - loadRefs() { - this.isLoading = true; - - axios - .get(this.projectRefsEndpoint, { - params: { - search: this.lowerCasedSearchTerm, - }, - }) - .then(({ data }) => { - // Note: These keys are uppercase in API - const { Branches = [], Tags = [] } = data; - - this.branches = formatRefs(Branches, BRANCH_REF_TYPE); - this.tags = formatRefs(Tags, TAG_REF_TYPE); - }) - .catch((e) => { - this.$emit('loadingError', e); - }) - .finally(() => { - this.isLoading = false; - }); - }, - debouncedLoadRefs: debounce(function debouncedLoadRefs() { - this.loadRefs(); - }, DEBOUNCE_REFS_SEARCH_MS), - setRefSelected(ref) { - this.$emit('input', ref); - }, - isSelected(ref) { - return ref.fullName === this.value.fullName; - }, - }, -}; -</script> -<template> - <gl-dropdown :text="refShortName" block data-testid="ref-select" @show.once="loadRefs"> - <gl-search-box-by-type - v-model.trim="searchTerm" - :is-loading="isLoading" - :placeholder="__('Search refs')" - data-testid="search-refs" - /> - <gl-dropdown-section-header>{{ __('Branches') }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="branch in branches" - :key="branch.fullName" - class="gl-font-monospace" - is-check-item - :is-checked="isSelected(branch)" - @click="setRefSelected(branch)" - > - {{ branch.shortName }} - </gl-dropdown-item> - <gl-dropdown-section-header v-if="hasTags">{{ __('Tags') }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="tag in tags" - :key="tag.fullName" - class="gl-font-monospace" - is-check-item - :is-checked="isSelected(tag)" - @click="setRefSelected(tag)" - > - {{ tag.shortName }} - </gl-dropdown-item> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/pipeline_new/utils/format_refs.js b/app/assets/javascripts/pipeline_new/utils/format_refs.js deleted file mode 100644 index f0fbc5ed7b6..00000000000 --- a/app/assets/javascripts/pipeline_new/utils/format_refs.js +++ /dev/null @@ -1,18 +0,0 @@ -import { BRANCH_REF_TYPE, TAG_REF_TYPE } from '../constants'; - -export default (refs, type) => { - let fullName; - - return refs.map((ref) => { - if (type === BRANCH_REF_TYPE) { - fullName = `refs/heads/${ref}`; - } else if (type === TAG_REF_TYPE) { - fullName = `refs/tags/${ref}`; - } - - return { - shortName: ref, - fullName, - }; - }); -}; diff --git a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue index adeb4ae598b..ab837d04d9a 100644 --- a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue +++ b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue @@ -6,6 +6,7 @@ import { merge } from '~/lib/utils/yaml'; import { __ } from '~/locale'; import { isValidStepSeq } from '~/pipeline_wizard/validators'; import Tracking from '~/tracking'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import YamlEditor from './editor.vue'; import WizardStep from './step.vue'; import CommitStep from './commit.vue'; @@ -28,7 +29,7 @@ export default { WizardStep, CommitStep, }, - mixins: [trackingMixin], + mixins: [trackingMixin, glFeatureFlagsMixin()], props: { steps: { type: Object, @@ -91,6 +92,11 @@ export default { category: `pipeline_wizard:${this.templateId}`, }; }, + trackingExtraData() { + return { + features: this.glFeatures, + }; + }, }, watch: { isLastStep(value) { @@ -125,6 +131,7 @@ export default { extra: { fromStep: this.currentStepIndex + 1, toStep: this.currentStepIndex, + ...this.trackingExtraData, }, }); }, @@ -136,6 +143,7 @@ export default { extra: { fromStep: this.currentStepIndex - 1, toStep: this.currentStepIndex, + ...this.trackingExtraData, }, }); }, @@ -144,6 +152,7 @@ export default { this.track('click_button', { label: 'pipeline_wizard_commit', property: 'commit', + extra: this.trackingExtraData, }); }, onEditorTouched() { @@ -151,6 +160,7 @@ export default { label: 'pipeline_wizard_editor_interaction', extra: { currentStep: this.currentStepIndex, + ...this.trackingExtraData, }, }); }, diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue index c56537f4039..041b62e02ec 100644 --- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue +++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue @@ -4,7 +4,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import { __, s__ } from '~/locale'; import { createAlert } from '~/flash'; import { redirectTo } from '~/lib/utils/url_utility'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import RetryFailedJobMutation from '../../graphql/mutations/retry_failed_job.mutation.graphql'; import { DEFAULT_FIELDS } from '../../constants'; @@ -12,7 +12,7 @@ export default { fields: DEFAULT_FIELDS, retry: __('Retry'), components: { - CiBadge, + CiBadgeLink, GlButton, GlLink, GlTableLite, @@ -72,7 +72,7 @@ export default { <div class="gl-display-flex gl-align-items-center gl-lg-justify-content-start gl-justify-content-end" > - <ci-badge :status="item.detailedStatus" :show-text="false" class="gl-mr-3" /> + <ci-badge-link :status="item.detailedStatus" :show-text="false" class="gl-mr-3" /> <div class="gl-text-truncate"> <gl-link :href="item.detailedStatus.detailsPath" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index 30528ce8d17..c498f12d5c7 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem, GlEmptyState, GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { GlEmptyState, GlIcon, GlLoadingIcon, GlCollapsibleListbox } from '@gitlab/ui'; import { isEqual } from 'lodash'; import { createAlert, VARIANT_INFO, VARIANT_WARNING } from '~/flash'; import { getParameterByName } from '~/lib/utils/url_utility'; @@ -26,8 +26,7 @@ export default { PipelineKeyOptions, components: { EmptyState, - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, GlEmptyState, GlIcon, GlLoadingIcon, @@ -315,7 +314,7 @@ export default { this.updateContent(this.requestData); }, changeVisibilityPipelineID(val) { - this.selectedPipelineKeyOption = val; + this.selectedPipelineKeyOption = PipelineKeyOptions.find((e) => val === e.value); }, }, }; @@ -355,21 +354,12 @@ export default { :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> + <gl-collapsible-listbox + data-testid="pipeline-key-collapsible-box" + :toggle-text="selectedPipelineKeyOption.text" + :items="$options.PipelineKeyOptions" + @select="changeVisibilityPipelineID" + /> </div> </div> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue index 936ae4da1ec..00ab8a25ca1 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue @@ -1,12 +1,12 @@ <script> import { CHILD_VIEW, TRACKING_CATEGORIES } from '~/pipelines/constants'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import Tracking from '~/tracking'; import PipelinesTimeago from './time_ago.vue'; export default { components: { - CiBadge, + CiBadgeLink, PipelinesTimeago, }, mixins: [Tracking.mixin()], @@ -38,14 +38,13 @@ export default { <template> <div> - <ci-badge + <ci-badge-link class="gl-mb-3" :status="pipelineStatus" :show-text="!isChildView" - :icon-classes="'gl-vertical-align-middle!'" data-qa-selector="pipeline_commit_status" @ciStatusBadgeClick="trackClick" /> - <pipelines-timeago class="gl-mt-3" :pipeline="pipeline" /> + <pipelines-timeago :pipeline="pipeline" /> </div> </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 346f5735576..ed32d643c0e 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -161,7 +161,7 @@ export default { <pipeline-url :pipeline="item" :pipeline-schedule-url="pipelineScheduleUrl" - :pipeline-key="pipelineKeyOption.key" + :pipeline-key="pipelineKeyOption.value" /> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue index cd44c998074..960af030421 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue @@ -19,53 +19,51 @@ export default { duration() { return this.pipeline?.details?.duration; }, - finishedTime() { - return this.pipeline?.details?.finished_at; - }, - skipped() { - return this.pipeline?.details?.status?.label === 'skipped'; - }, - stuck() { - return this.pipeline.flags.stuck; - }, durationFormatted() { return durationTimeFormatted(this.duration); }, + finishedTime() { + return this.pipeline?.details?.finished_at; + }, showInProgress() { return !this.duration && !this.finishedTime && !this.skipped; }, showSkipped() { return !this.duration && !this.finishedTime && this.skipped; }, + skipped() { + return this.pipeline?.details?.status?.label === 'skipped'; + }, + stuck() { + return this.pipeline.flags.stuck; + }, }, }; </script> <template> - <div class="gl-display-block"> - <span v-if="showInProgress" data-testid="pipeline-in-progress"> + <div class="gl-display-flex gl-flex-direction-column time-ago"> + <span + v-if="showInProgress" + class="gl-display-inline-flex gl-align-items-center" + data-testid="pipeline-in-progress" + > <gl-icon v-if="stuck" name="warning" class="gl-mr-2" :size="12" data-testid="warning-icon" /> - <gl-icon - v-else - name="hourglass" - class="gl-vertical-align-baseline! gl-mr-2" - :size="12" - data-testid="hourglass-icon" - /> + <gl-icon v-else name="hourglass" class="gl-mr-2" :size="12" data-testid="hourglass-icon" /> {{ s__('Pipeline|In progress') }} </span> <span v-if="showSkipped" data-testid="pipeline-skipped"> - <gl-icon name="status_skipped_borderless" class="gl-mr-2" :size="16" /> + <gl-icon name="status_skipped_borderless" /> {{ s__('Pipeline|Skipped') }} </span> - <p v-if="duration" class="duration"> - <gl-icon name="timer" class="gl-vertical-align-baseline!" :size="12" /> + <p v-if="duration" class="duration gl-display-inline-flex gl-align-items-center"> + <gl-icon name="timer" class="gl-mr-2" :size="12" /> {{ durationFormatted }} </p> - <p v-if="finishedTime" class="finished-at d-none d-md-block"> - <gl-icon name="calendar" class="gl-vertical-align-baseline!" :size="12" /> + <p v-if="finishedTime" class="finished-at gl-display-inline-flex gl-align-items-center"> + <gl-icon name="calendar" class="gl-mr-2" :size="12" /> <time v-gl-tooltip diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index ed8ec614304..2f37f90e625 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -71,12 +71,12 @@ export const PipelineKeyOptions = [ { text: __('Show Pipeline ID'), label: __('Pipeline ID'), - key: 'id', + value: 'id', }, { text: __('Show Pipeline IID'), label: __('Pipeline IID'), - key: 'iid', + value: 'iid', }, ]; diff --git a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js index 07551c2342f..e6770b71113 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js @@ -2,7 +2,7 @@ import Visibility from 'visibilityjs'; import { createAlert } from '~/flash'; import { helpPagePath } from '~/helpers/help_page_helper'; import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_UNAUTHORIZED } from '~/lib/utils/http_status'; import Poll from '~/lib/utils/poll'; import { __ } from '~/locale'; import { validateParams } from '~/pipelines/utils'; @@ -196,7 +196,7 @@ export default { this.updateTable(); }) .catch((e) => { - const unauthorized = e.response.status === httpStatusCodes.UNAUTHORIZED; + const unauthorized = e.response.status === HTTP_STATUS_UNAUTHORIZED; let errorMessage = __( 'An error occurred while trying to run a new pipeline for this merge request.', ); diff --git a/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js b/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js index 6520e68d41c..8e4d42a42c6 100644 --- a/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js +++ b/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js @@ -1,7 +1,10 @@ import Vue from 'vue'; +import { initListboxInputs } from '~/vue_shared/components/listbox_input/init_listbox_inputs'; import ProfilePreferences from './components/profile_preferences.vue'; export default () => { + initListboxInputs(); + const el = document.querySelector('#js-profile-preferences-app'); const formEl = document.querySelector('#profile-preferences-form'); const shouldParse = ['integrationViews', 'themes', 'userFields']; diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index 93bc203d391..c031c5e5e8e 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -30,10 +30,6 @@ export default class Profile { bindEvents() { $('.js-preferences-form').on('change.preference', 'input[type=radio]', this.submitForm); - $('.js-group-notification-email').on('change', this.submitForm); - $('#user_notification_email').on('select2-selecting', (event) => { - setTimeout(this.submitForm.bind(event.currentTarget)); - }); $('#user_email_opted_in').on('change', this.submitForm); $('#user_notified_of_own_activity').on('change', this.submitForm); this.form.on('submit', this.onSubmitForm); diff --git a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue index a037e721677..a1fc3f1a731 100644 --- a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue +++ b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue @@ -1,11 +1,5 @@ <script> -import { - GlDropdown, - GlSearchBoxByType, - GlDropdownItem, - GlDropdownText, - GlLoadingIcon, -} from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import { I18N_NO_RESULTS_MESSAGE, @@ -16,11 +10,7 @@ import { export default { name: 'BranchesDropdown', components: { - GlDropdown, - GlSearchBoxByType, - GlDropdownItem, - GlDropdownText, - GlLoadingIcon, + GlCollapsibleListbox, }, props: { value: { @@ -46,13 +36,16 @@ export default { }, computed: { ...mapGetters(['joinedBranches']), - ...mapState(['isFetching', 'branch', 'branches']), + ...mapState(['isFetching']), filteredResults() { const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); return this.joinedBranches.filter((resultString) => resultString.toLowerCase().includes(lowerCasedSearchTerm), ); }, + listboxItems() { + return this.filteredResults.map((value) => ({ value, text: value })); + }, }, watch: { // Parent component can set the branch value (e.g. when the user selects a different project) @@ -68,10 +61,6 @@ export default { ...mapActions(['fetchBranches']), selectBranch(branch) { this.$emit('selectBranch', branch); - this.searchTerm = branch; // enables isSelected to work as expected - }, - isSelected(selectedBranch) { - return selectedBranch === this.branch; }, searchTermChanged(value) { this.searchTerm = value; @@ -81,36 +70,16 @@ export default { }; </script> <template> - <gl-dropdown :text="value" :header-text="$options.i18n.branchHeaderTitle"> - <gl-search-box-by-type - :value="searchTerm" - trim - autocomplete="off" - :debounce="250" - :placeholder="$options.i18n.branchSearchPlaceholder" - data-testid="dropdown-search-box" - @input="searchTermChanged" - /> - <gl-dropdown-item - v-for="branch in filteredResults" - v-show="!isFetching" - :key="branch" - :name="branch" - :is-checked="isSelected(branch)" - is-check-item - data-testid="dropdown-item" - @click="selectBranch(branch)" - > - {{ branch }} - </gl-dropdown-item> - <gl-dropdown-text v-show="isFetching" data-testid="dropdown-text-loading-icon"> - <gl-loading-icon size="sm" class="gl-mx-auto" /> - </gl-dropdown-text> - <gl-dropdown-text - v-if="!filteredResults.length && !isFetching" - data-testid="empty-result-message" - > - <span class="gl-text-gray-500">{{ $options.i18n.noResultsMessage }}</span> - </gl-dropdown-text> - </gl-dropdown> + <gl-collapsible-listbox + :header-text="$options.i18n.branchHeaderTitle" + :toggle-text="value" + :items="listboxItems" + searchable + :search-placeholder="$options.i18n.branchSearchPlaceholder" + :searching="isFetching" + :selected="value" + :no-results-text="$options.i18n.noResultsMessage" + @search="searchTermChanged" + @select="selectBranch" + /> </template> diff --git a/app/assets/javascripts/projects/commit/components/form_modal.vue b/app/assets/javascripts/projects/commit/components/form_modal.vue index 1febe8ceaab..b31ba4a100c 100644 --- a/app/assets/javascripts/projects/commit/components/form_modal.vue +++ b/app/assets/javascripts/projects/commit/components/form_modal.vue @@ -141,11 +141,7 @@ export default { :value="targetProjectId" /> - <projects-dropdown - class="gl-w-half" - :value="targetProjectName" - @selectProject="setSelectedProject" - /> + <projects-dropdown :value="targetProjectName" @selectProject="setSelectedProject" /> </gl-form-group> <gl-form-group @@ -155,12 +151,7 @@ export default { > <input id="start_branch" type="hidden" name="start_branch" :value="branch" /> - <branches-dropdown - class="gl-w-half" - :value="branch" - :blanked="isRevert" - @selectBranch="setBranch" - /> + <branches-dropdown :value="branch" :blanked="isRevert" @selectBranch="setBranch" /> </gl-form-group> <gl-form-checkbox diff --git a/app/assets/javascripts/projects/commit/components/projects_dropdown.vue b/app/assets/javascripts/projects/commit/components/projects_dropdown.vue index 6288bcdaad0..d43f5b99e2c 100644 --- a/app/assets/javascripts/projects/commit/components/projects_dropdown.vue +++ b/app/assets/javascripts/projects/commit/components/projects_dropdown.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlSearchBoxByType, GlDropdownItem, GlDropdownText } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { mapGetters, mapState } from 'vuex'; import { I18N_NO_RESULTS_MESSAGE, @@ -10,10 +10,7 @@ import { export default { name: 'ProjectsDropdown', components: { - GlDropdown, - GlSearchBoxByType, - GlDropdownItem, - GlDropdownText, + GlCollapsibleListbox, }, props: { value: { @@ -41,17 +38,20 @@ export default { project.name.toLowerCase().includes(lowerCasedFilterTerm), ); }, + listboxItems() { + return this.filteredResults.map(({ id, name }) => ({ value: id, text: name })); + }, selectedProject() { return this.sortedProjects.find((project) => project.id === this.targetProjectId) || {}; }, }, methods: { - selectProject(project) { - this.$emit('selectProject', project.id); - this.filterTerm = project.name; // when we select a project, we want the dropdown to filter to the selected project - }, - isSelected(selectedProject) { - return selectedProject === this.selectedProject; + selectProject(value) { + this.$emit('selectProject', value); + + // when we select a project, we want the dropdown to filter to the selected project + const project = this.listboxItems.find((x) => x.value === value); + this.filterTerm = project?.text || ''; }, filterTermChanged(value) { this.filterTerm = value; @@ -60,28 +60,15 @@ export default { }; </script> <template> - <gl-dropdown :text="selectedProject.name" :header-text="$options.i18n.projectHeaderTitle"> - <gl-search-box-by-type - :value="filterTerm" - trim - autocomplete="off" - :placeholder="$options.i18n.projectSearchPlaceholder" - data-testid="dropdown-search-box" - @input="filterTermChanged" - /> - <gl-dropdown-item - v-for="project in filteredResults" - :key="project.name" - :name="project.name" - :is-checked="isSelected(project)" - is-check-item - data-testid="dropdown-item" - @click="selectProject(project)" - > - {{ project.name }} - </gl-dropdown-item> - <gl-dropdown-text v-if="!filteredResults.length" data-testid="empty-result-message"> - <span class="gl-text-gray-500">{{ $options.i18n.noResultsMessage }}</span> - </gl-dropdown-text> - </gl-dropdown> + <gl-collapsible-listbox + :header-text="$options.i18n.projectHeaderTitle" + :items="listboxItems" + searchable + :search-placeholder="$options.i18n.projectSearchPlaceholder" + :selected="selectedProject.id" + :toggle-text="selectedProject.name" + :no-results-text="$options.i18n.noResultsMessage" + @search="filterTermChanged" + @select="selectProject" + /> </template> diff --git a/app/assets/javascripts/projects/commits/index.js b/app/assets/javascripts/projects/commits/index.js index 53169f689c9..f56884f605f 100644 --- a/app/assets/javascripts/projects/commits/index.js +++ b/app/assets/javascripts/projects/commits/index.js @@ -33,20 +33,31 @@ export const initCommitsRefSwitcher = () => { if (!el) return false; - const { projectId, ref, commitsPath } = el.dataset; + const { projectId, ref, commitsPath, refType } = el.dataset; const commitsPathPrefix = commitsPath.match(COMMITS_PATH_REGEX)?.[0]; - + const useSymbolicRefNames = Boolean(refType); return new Vue({ el, render(createElement) { return createElement(RefSelector, { props: { projectId, - value: ref, + value: useSymbolicRefNames ? `refs/${refType}/${ref}` : ref, + useSymbolicRefNames, + refType, }, on: { input(selected) { - visitUrl(`${commitsPathPrefix}/${selected}`); + if (useSymbolicRefNames) { + const matches = selected.match(/refs\/(heads|tags)\/(.+)/); + if (matches) { + visitUrl(`${commitsPathPrefix}/${matches[2]}?ref_type=${matches[1]}`); + } else { + visitUrl(`${commitsPathPrefix}/${selected}`); + } + } else { + visitUrl(`${commitsPathPrefix}/${selected}`); + } }, }, }); diff --git a/app/assets/javascripts/projects/merge_requests/components/report_abuse_dropdown_item.vue b/app/assets/javascripts/projects/merge_requests/components/report_abuse_dropdown_item.vue new file mode 100644 index 00000000000..31890249f41 --- /dev/null +++ b/app/assets/javascripts/projects/merge_requests/components/report_abuse_dropdown_item.vue @@ -0,0 +1,41 @@ +<script> +import { GlDropdownItem } from '@gitlab/ui'; +import { MountingPortal } from 'portal-vue'; +import { s__ } from '~/locale'; + +import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; + +export default { + name: 'ReportAbuseDropdownItem', + components: { + GlDropdownItem, + MountingPortal, + AbuseCategorySelector, + }, + i18n: { + reportAbuse: s__('ReportAbuse|Report abuse to administrator'), + }, + data() { + return { + open: false, + }; + }, + methods: { + openDrawer() { + this.open = true; + }, + closeDrawer() { + this.open = false; + }, + }, +}; +</script> +<template> + <span> + <gl-dropdown-item @click="openDrawer">{{ $options.i18n.reportAbuse }}</gl-dropdown-item> + + <mounting-portal mount-to="#js-report-abuse-drawer" name="abuse-category-selector" append> + <abuse-category-selector :show-drawer="open" @close-drawer="closeDrawer" /> + </mounting-portal> + </span> +</template> diff --git a/app/assets/javascripts/projects/merge_requests/index.js b/app/assets/javascripts/projects/merge_requests/index.js new file mode 100644 index 00000000000..25a70121d68 --- /dev/null +++ b/app/assets/javascripts/projects/merge_requests/index.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import ReportAbuseDropdownItem from './components/report_abuse_dropdown_item.vue'; + +export const initReportAbuse = () => { + const el = document.getElementById('js-report-abuse-dropdown-item'); + + if (!el) return false; + + const { reportAbusePath, reportedUserId, reportedFromUrl } = el.dataset; + + return new Vue({ + el, + provide: { reportAbusePath, reportedUserId, reportedFromUrl }, + render(createElement) { + return createElement(ReportAbuseDropdownItem); + }, + }); +}; diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue index 626ed67c466..6260c8dd4d0 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue @@ -56,6 +56,7 @@ export default { }, update({ project: { branchRules } }) { const branchRule = branchRules.nodes.find((rule) => rule.name === this.branch); + this.branchRule = branchRule; this.branchProtection = branchRule?.branchProtection; this.approvalRules = branchRule?.approvalRules; this.statusChecks = branchRule?.externalStatusChecks?.nodes || []; @@ -69,6 +70,7 @@ export default { branchProtection: {}, approvalRules: {}, statusChecks: [], + branchRule: {}, matchingBranchesCount: null, }; }, @@ -88,12 +90,12 @@ export default { }, allowedToMergeHeader() { return sprintf(this.$options.i18n.allowedToMergeHeader, { - total: this.mergeAccessLevels.total, + total: this.mergeAccessLevels?.total || 0, }); }, allowedToPushHeader() { return sprintf(this.$options.i18n.allowedToPushHeader, { - total: this.pushAccessLevels.total, + total: this.pushAccessLevels?.total || 0, }); }, approvalsHeader() { @@ -141,7 +143,7 @@ export default { <template> <gl-loading-icon v-if="$apollo.loading" /> - <div v-else-if="!branchProtection">{{ $options.i18n.noData }}</div> + <div v-else-if="!branchRule">{{ $options.i18n.noData }}</div> <div v-else> <strong data-testid="branch-title">{{ branchTitle }}</strong> <p v-if="!allBranches" class="gl-mb-3 gl-text-gray-400"> diff --git a/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js b/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js index 5ca864a412b..54120b3525d 100644 --- a/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js +++ b/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js @@ -4,6 +4,10 @@ import SharedRunnersToggle from '~/projects/settings/components/shared_runners_t export default (containerId = 'toggle-shared-runners-form') => { const containerEl = document.getElementById(containerId); + if (!containerEl) { + return null; + } + const { isDisabledAndUnoverridable, isEnabled, diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue index 41947834bdb..4a24df4b0dc 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue @@ -5,6 +5,7 @@ import { getAccessLevels } from '../../../utils'; export const i18n = { defaultLabel: s__('BranchRules|default'), + protectedLabel: s__('BranchRules|protected'), detailsButtonLabel: s__('BranchRules|Details'), allowForcePush: s__('BranchRules|Allowed to force push'), codeOwnerApprovalRequired: s__('BranchRules|Requires CODEOWNERS approval'), @@ -62,6 +63,9 @@ export default { isWildcard() { return this.name.includes('*'); }, + isProtected() { + return Boolean(this.branchProtection); + }, hasApprovalDetails() { return this.approvalDetails.length; }, @@ -105,10 +109,10 @@ export default { if (this.isWildcard) { approvalDetails.push(this.matchingBranchesText); } - if (this.branchProtection.allowForcePush) { + if (this.branchProtection?.allowForcePush) { approvalDetails.push(this.$options.i18n.allowForcePush); } - if (this.branchProtection.codeOwnerApprovalRequired) { + if (this.branchProtection?.codeOwnerApprovalRequired) { approvalDetails.push(this.$options.i18n.codeOwnerApprovalRequired); } if (this.statusChecksTotal) { @@ -154,6 +158,10 @@ export default { $options.i18n.defaultLabel }}</gl-badge> + <gl-badge v-if="isProtected" variant="success" size="sm" class="gl-ml-2">{{ + $options.i18n.protectedLabel + }}</gl-badge> + <ul v-if="hasApprovalDetails" class="gl-pl-6 gl-mt-2 gl-mb-0 gl-text-gray-500"> <li v-for="(detail, index) in approvalDetails" :key="index">{{ detail }}</li> </ul> diff --git a/app/assets/javascripts/read_more.js b/app/assets/javascripts/read_more.js index 769782607b8..692f375bb94 100644 --- a/app/assets/javascripts/read_more.js +++ b/app/assets/javascripts/read_more.js @@ -31,9 +31,9 @@ export default function initReadMore(triggerSelector = '.js-read-more-trigger') triggerEl.addEventListener( 'click', - (e) => { + () => { targetEl.classList.add('is-expanded'); - e.target.remove(); + triggerEl.remove(); }, { once: true }, ); diff --git a/app/assets/javascripts/ref/components/ref_results_section.vue b/app/assets/javascripts/ref/components/ref_results_section.vue index 4fa2a92ff03..52d1ed96b21 100644 --- a/app/assets/javascripts/ref/components/ref_results_section.vue +++ b/app/assets/javascripts/ref/components/ref_results_section.vue @@ -74,6 +74,11 @@ export default { required: false, default: '', }, + shouldShowCheck: { + type: Boolean, + required: false, + default: true, + }, }, computed: { totalCountText() { @@ -82,6 +87,9 @@ export default { }, methods: { showCheck(item) { + if (!this.shouldShowCheck) { + return false; + } return item.name === this.selectedRef || item.value === this.selectedRef; }, }, diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue index b75958e2ced..10967fb84ed 100644 --- a/app/assets/javascripts/ref/components/ref_selector.vue +++ b/app/assets/javascripts/ref/components/ref_selector.vue @@ -15,6 +15,8 @@ import { REF_TYPE_BRANCHES, REF_TYPE_TAGS, REF_TYPE_COMMITS, + BRANCH_REF_TYPE, + TAG_REF_TYPE, } from '../constants'; import createStore from '../stores'; import RefResultsSection from './ref_results_section.vue'; @@ -50,6 +52,11 @@ export default { required: false, default: '', }, + refType: { + type: String, + required: false, + default: null, + }, projectId: { type: String, required: true, @@ -146,6 +153,12 @@ export default { buttonText() { return this.selectedRefForDisplay || this.i18n.noRefSelected; }, + isTagRefType() { + return this.refType === TAG_REF_TYPE; + }, + isBranchRefType() { + return this.refType === BRANCH_REF_TYPE; + }, }, watch: { // Keep the Vuex store synchronized if the parent @@ -273,6 +286,7 @@ export default { :show-header="showSectionHeaders" data-testid="branches-section" data-qa-selector="branches_section" + :should-show-check="!useSymbolicRefNames || isBranchRefType" @selected="selectRef($event)" /> @@ -289,6 +303,7 @@ export default { :error-message="i18n.tagsErrorMessage" :show-header="showSectionHeaders" data-testid="tags-section" + :should-show-check="!useSymbolicRefNames || isTagRefType" @selected="selectRef($event)" /> diff --git a/app/assets/javascripts/ref/constants.js b/app/assets/javascripts/ref/constants.js index 397e3ed2ac8..f4faa535166 100644 --- a/app/assets/javascripts/ref/constants.js +++ b/app/assets/javascripts/ref/constants.js @@ -5,6 +5,8 @@ export const REF_TYPE_BRANCHES = 'REF_TYPE_BRANCHES'; export const REF_TYPE_TAGS = 'REF_TYPE_TAGS'; export const REF_TYPE_COMMITS = 'REF_TYPE_COMMITS'; export const ALL_REF_TYPES = Object.freeze([REF_TYPE_BRANCHES, REF_TYPE_TAGS, REF_TYPE_COMMITS]); +export const BRANCH_REF_TYPE = 'heads'; +export const TAG_REF_TYPE = 'tags'; export const X_TOTAL_HEADER = 'x-total'; diff --git a/app/assets/javascripts/ref/stores/mutations.js b/app/assets/javascripts/ref/stores/mutations.js index e078d3333d4..9846ac0adb7 100644 --- a/app/assets/javascripts/ref/stores/mutations.js +++ b/app/assets/javascripts/ref/stores/mutations.js @@ -1,5 +1,5 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status'; import { X_TOTAL_HEADER } from '../constants'; import * as types from './mutation_types'; @@ -86,7 +86,7 @@ export default { // 404's are expected when the search query doesn't match any commits // and shouldn't be treated as an actual error - error: error.response?.status !== httpStatusCodes.NOT_FOUND ? error : null, + error: error.response?.status !== HTTP_STATUS_NOT_FOUND ? error : null, }; }, [types.RESET_COMMIT_MATCHES](state) { diff --git a/app/assets/javascripts/ref_select_dropdown.js b/app/assets/javascripts/ref_select_dropdown.js deleted file mode 100644 index c283fb1ea08..00000000000 --- a/app/assets/javascripts/ref_select_dropdown.js +++ /dev/null @@ -1,51 +0,0 @@ -import $ from 'jquery'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; - -class RefSelectDropdown { - constructor($dropdownButton, availableRefs) { - const availableRefsValue = - availableRefs || JSON.parse(document.getElementById('availableRefs').innerHTML); - initDeprecatedJQueryDropdown($dropdownButton, { - data: availableRefsValue, - filterable: true, - filterByText: true, - remote: false, - fieldName: $dropdownButton.data('fieldName'), - filterInput: 'input[type="search"]', - selectable: true, - isSelectable(branch, $el) { - return !$el.hasClass('is-active'); - }, - text(branch) { - return branch; - }, - id(branch) { - return branch; - }, - toggleLabel(branch) { - return branch; - }, - }); - - const $dropdownContainer = $dropdownButton.closest('.dropdown'); - const $fieldInput = $(`input[name="${$dropdownButton.data('fieldName')}"]`, $dropdownContainer); - const $filterInput = $('input[type="search"]', $dropdownContainer); - - $filterInput.on('keyup', (e) => { - const keyCode = e.keyCode || e.which; - if (keyCode !== 13) return; - - const ref = $filterInput.val().trim(); - if (ref === '') { - return; - } - - $fieldInput.val(ref); - $('.dropdown-toggle-text', $dropdownButton).text(ref); - - $dropdownContainer.removeClass('open'); - }); - } -} - -export default RefSelectDropdown; diff --git a/app/assets/javascripts/repository/commits_service.js b/app/assets/javascripts/repository/commits_service.js index f009c0310c5..d029f8cf89f 100644 --- a/app/assets/javascripts/repository/commits_service.js +++ b/app/assets/javascripts/repository/commits_service.js @@ -35,7 +35,7 @@ const fetchData = (projectPath, path, ref, offset) => { gon.relative_url_root || '/', projectPath, '/-/refs/', - ref, + encodeURIComponent(ref), '/logs_tree/', encodeURIComponent(removeLeadingSlash(path)), ); diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js index 3e6d2e675ed..a480710f8ac 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/index.js +++ b/app/assets/javascripts/repository/components/blob_viewers/index.js @@ -10,6 +10,8 @@ const viewers = { audio: () => import('./audio_viewer.vue'), svg: () => import('./image_viewer.vue'), sketch: () => import('./sketch_viewer.vue'), + notebook: () => import('./notebook_viewer.vue'), + openapi: () => import('./openapi_viewer.vue'), }; export const loadViewer = (type, isUsingLfs) => { diff --git a/app/assets/javascripts/repository/components/blob_viewers/notebook_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/notebook_viewer.vue new file mode 100644 index 00000000000..1114a0942ec --- /dev/null +++ b/app/assets/javascripts/repository/components/blob_viewers/notebook_viewer.vue @@ -0,0 +1,31 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import notebookLoader from '~/blob/notebook'; +import { stripPathTail } from '~/lib/utils/url_utility'; + +export default { + components: { + GlLoadingIcon, + }, + props: { + blob: { + type: Object, + required: true, + }, + }, + data() { + return { + url: this.blob.rawPath, + }; + }, + mounted() { + notebookLoader({ el: this.$refs.viewer, relativeRawPath: stripPathTail(this.url) }); + }, +}; +</script> + +<template> + <div ref="viewer" :data-endpoint="url" data-testid="notebook"> + <gl-loading-icon class="gl-my-4" size="lg" /> + </div> +</template> diff --git a/app/assets/javascripts/repository/components/blob_viewers/openapi_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/openapi_viewer.vue new file mode 100644 index 00000000000..5665e4b0ec4 --- /dev/null +++ b/app/assets/javascripts/repository/components/blob_viewers/openapi_viewer.vue @@ -0,0 +1,24 @@ +<script> +import renderOpenApi from '~/blob/openapi'; + +export default { + props: { + blob: { + type: Object, + required: true, + }, + }, + data() { + return { + url: this.blob.rawPath, + }; + }, + mounted() { + renderOpenApi(this.$refs.viewer); + }, +}; +</script> + +<template> + <div ref="viewer" class="file-content" :data-endpoint="url" data-testid="openapi"></div> +</template> diff --git a/app/assets/javascripts/repository/components/fork_info.vue b/app/assets/javascripts/repository/components/fork_info.vue new file mode 100644 index 00000000000..980fa140eb5 --- /dev/null +++ b/app/assets/javascripts/repository/components/fork_info.vue @@ -0,0 +1,146 @@ +<script> +import { GlIcon, GlLink, GlSkeletonLoader } from '@gitlab/ui'; +import { s__, sprintf, n__ } from '~/locale'; +import { createAlert } from '~/flash'; +import forkDetailsQuery from '../queries/fork_details.query.graphql'; + +export const i18n = { + forkedFrom: s__('ForkedFromProjectPath|Forked from'), + inaccessibleProject: s__('ForkedFromProjectPath|Forked from an inaccessible project.'), + upToDate: s__('ForksDivergence|Up to date with the upstream repository.'), + unknown: s__('ForksDivergence|This fork has diverged from the upstream repository.'), + behind: s__('ForksDivergence|%{behind} %{commit_word} behind'), + ahead: s__('ForksDivergence|%{ahead} %{commit_word} ahead of'), + behindAndAhead: s__('ForksDivergence|%{messages} the upstream repository.'), + error: s__('ForksDivergence|Failed to fetch fork details. Try again later.'), +}; + +export default { + i18n, + components: { + GlIcon, + GlLink, + GlSkeletonLoader, + }, + apollo: { + project: { + query: forkDetailsQuery, + variables() { + return { + projectPath: this.projectPath, + ref: this.selectedRef, + }; + }, + skip() { + return !this.sourceName; + }, + error(error) { + createAlert({ + message: this.$options.i18n.error, + captureError: true, + error, + }); + }, + }, + }, + props: { + projectPath: { + type: String, + required: true, + }, + selectedRef: { + type: String, + required: true, + }, + sourceName: { + type: String, + required: false, + default: '', + }, + sourcePath: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + project: { + forkDetails: { + ahead: null, + behind: null, + }, + }, + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.project.loading; + }, + ahead() { + return this.project?.forkDetails?.ahead; + }, + behind() { + return this.project?.forkDetails?.behind; + }, + behindText() { + return sprintf(this.$options.i18n.behind, { + behind: this.behind, + commit_word: n__('commit', 'commits', this.behind), + }); + }, + aheadText() { + return sprintf(this.$options.i18n.ahead, { + ahead: this.ahead, + commit_word: n__('commit', 'commits', this.ahead), + }); + }, + isUnknownDivergence() { + return (!this.ahead && this.ahead !== 0) || (!this.behind && this.behind !== 0); + }, + behindAheadMessage() { + const messages = []; + if (this.behind > 0) { + messages.push(this.behindText); + } + if (this.ahead > 0) { + messages.push(this.aheadText); + } + return messages.join(', '); + }, + hasBehindAheadMessage() { + return this.behindAheadMessage.length > 0; + }, + forkDivergenceMessage() { + if (this.isUnknownDivergence) { + return this.$options.i18n.unknown; + } + if (this.hasBehindAheadMessage) { + return sprintf(this.$options.i18n.behindAndAhead, { + messages: this.behindAheadMessage, + }); + } + return this.$options.i18n.upToDate; + }, + }, +}; +</script> + +<template> + <div class="info-well gl-sm-display-flex gl-flex-direction-column"> + <div class="well-segment gl-p-5 gl-w-full gl-display-flex"> + <gl-icon name="fork" :size="16" class="gl-display-block gl-m-4 gl-text-center" /> + <div v-if="sourceName"> + {{ $options.i18n.forkedFrom }} + <gl-link data-qa-selector="forked_from_link" :href="sourcePath">{{ sourceName }}</gl-link> + <gl-skeleton-loader v-if="isLoading" :lines="1" /> + <div v-else class="gl-text-secondary"> + {{ forkDivergenceMessage }} + </div> + </div> + <div v-else data-testid="inaccessible-project" class="gl-align-items-center gl-display-flex"> + {{ $options.i18n.inaccessibleProject }} + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index 4a8f83458f4..f6d6004ba96 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -2,12 +2,13 @@ import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql'; import { createAlert } from '~/flash'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { __ } from '~/locale'; import { TREE_PAGE_SIZE, TREE_INITIAL_FETCH_COUNT, TREE_PAGE_LIMIT, COMMIT_BATCH_SIZE, + GITALY_UNAVAILABLE_CODE, + i18n, } from '../constants'; import getRefMixin from '../mixins/get_ref'; import projectPathQuery from '../queries/project_path.query.graphql'; @@ -17,6 +18,7 @@ import FilePreview from './preview/index.vue'; import FileTable from './table/index.vue'; export default { + i18n, components: { FileTable, FilePreview, @@ -142,10 +144,19 @@ export default { } }) .catch((error) => { + let gitalyUnavailableError; + if (error.graphQLErrors) { + gitalyUnavailableError = error.graphQLErrors.find( + (e) => e?.extensions?.code === GITALY_UNAVAILABLE_CODE, + ); + } + const message = gitalyUnavailableError + ? this.$options.i18n.gitalyError + : this.$options.i18n.generalError; createAlert({ - message: __('An error occurred while fetching folder content.'), + message, + captureError: true, }); - throw error; }); }, normalizeData(key, data) { diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index e194bddcc56..5098053c4f7 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -1,5 +1,6 @@ import { __ } from '~/locale'; +export const GITALY_UNAVAILABLE_CODE = 'unavailable'; export const TREE_PAGE_LIMIT = 1000; // the maximum amount of items per page export const TREE_PAGE_SIZE = 100; // the amount of items to be fetched per (batch) request export const TREE_INITIAL_FETCH_COUNT = TREE_PAGE_LIMIT / TREE_PAGE_SIZE; // the amount of (batch) requests to make @@ -100,3 +101,8 @@ export const LEGACY_FILE_TYPES = [ 'cargo_toml', 'go_mod', ]; + +export const i18n = { + generalError: __('An error occurred while fetching folder content.'), + gitalyError: __('Error: Gitaly is unavailable. Contact your administrator.'), +}; diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index e9214e3acff..e5d22f50d72 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -11,6 +11,7 @@ import RefSelector from '~/ref/components/ref_selector.vue'; import App from './components/app.vue'; import Breadcrumbs from './components/breadcrumbs.vue'; import DirectoryDownloadLinks from './components/directory_download_links.vue'; +import ForkInfo from './components/fork_info.vue'; import LastCommit from './components/last_commit.vue'; import BlobControls from './components/blob_controls.vue'; import apolloProvider from './graphql'; @@ -63,6 +64,28 @@ export default function setupVueRepositoryList() { }, }); + const initForkInfo = () => { + const forkEl = document.getElementById('js-fork-info'); + if (!forkEl) { + return null; + } + const { sourceName, sourcePath } = forkEl.dataset; + return new Vue({ + el: forkEl, + apolloProvider, + render(h) { + return h(ForkInfo, { + props: { + projectPath, + selectedRef: ref, + sourceName, + sourcePath, + }, + }); + }, + }); + }; + const initLastCommitApp = () => new Vue({ el: document.getElementById('js-last-commit'), @@ -118,6 +141,7 @@ export default function setupVueRepositoryList() { initLastCommitApp(); initBlobControlsApp(); + initForkInfo(); initRefSwitcher(); router.afterEach(({ params: { path } }) => { diff --git a/app/assets/javascripts/repository/queries/fork_details.query.graphql b/app/assets/javascripts/repository/queries/fork_details.query.graphql new file mode 100644 index 00000000000..d1a37d00d55 --- /dev/null +++ b/app/assets/javascripts/repository/queries/fork_details.query.graphql @@ -0,0 +1,9 @@ +query getForkDetails($projectPath: ID!, $ref: String) { + project(fullPath: $projectPath) { + id + forkDetails(ref: $ref) { + ahead + behind + } + } +} diff --git a/app/assets/javascripts/repository/utils/ref_switcher_utils.js b/app/assets/javascripts/repository/utils/ref_switcher_utils.js index 8ff52104c93..f296b5e9b4a 100644 --- a/app/assets/javascripts/repository/utils/ref_switcher_utils.js +++ b/app/assets/javascripts/repository/utils/ref_switcher_utils.js @@ -17,6 +17,7 @@ const NAMESPACE_TARGET_REGEX = /(\/-\/(blob|tree))\/.*?\/(.*)/; */ export function generateRefDestinationPath(projectRootPath, selectedRef) { const currentPath = window.location.pathname; + const encodedHash = '%23'; let namespace = '/-/tree'; let target; const match = NAMESPACE_TARGET_REGEX.exec(currentPath); @@ -24,7 +25,12 @@ export function generateRefDestinationPath(projectRootPath, selectedRef) { [, namespace, , target] = match; } - const destinationPath = joinPaths(projectRootPath, namespace, selectedRef, target); + const destinationPath = joinPaths( + projectRootPath, + namespace, + encodeURI(selectedRef).replace(/#/g, encodedHash), + target, + ); return `${destinationPath}${window.location.hash}`; } diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js index ba12f31ef87..d4ee857c9c1 100644 --- a/app/assets/javascripts/search/index.js +++ b/app/assets/javascripts/search/index.js @@ -1,6 +1,7 @@ import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result'; import { queryToObject } from '~/lib/utils/url_utility'; import refreshCounts from '~/pages/search/show/refresh_counts'; +import syntaxHighlight from '~/syntax_highlight'; import { initSidebar, sidebarInitState } from './sidebar'; import { initSearchSort } from './sort'; import createStore from './store'; @@ -8,10 +9,14 @@ import { initTopbar } from './topbar'; import { initBlobRefSwitcher } from './under_topbar'; export const initSearchApp = () => { - const query = queryToObject(window.location.search); - const navigation = sidebarInitState(); + syntaxHighlight(document.querySelectorAll('.js-search-results')); + const query = queryToObject(window.location.search, { gatherArrays: true }); + const { navigationJsonParsed: navigation } = sidebarInitState() || {}; - const store = createStore({ query, navigation }); + const store = createStore({ + query, + navigation, + }); initTopbar(store); initSidebar(store); diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue index 4ddf695f61a..fbfc24a94ae 100644 --- a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue @@ -21,6 +21,6 @@ export default { <template> <div> <radio-filter :class="ffBasedXPadding" :filter-data="$options.confidentialFilterData" /> - <hr class="gl-my-5 gl-border-gray-100" /> + <hr class="gl-my-5 gl-mx-5 gl-border-gray-100" /> </div> </template> diff --git a/app/assets/javascripts/search/sidebar/components/results_filters.vue b/app/assets/javascripts/search/sidebar/components/results_filters.vue index 9b993ab9a86..ff7a044736d 100644 --- a/app/assets/javascripts/search/sidebar/components/results_filters.vue +++ b/app/assets/javascripts/search/sidebar/components/results_filters.vue @@ -44,7 +44,7 @@ export default { <form class="gl-pt-5 gl-md-pt-0" @submit.prevent="applyQuery"> <hr v-if="searchPageVerticalNavFeatureFlag" - class="gl-my-5 gl-border-gray-100 gl-display-none gl-md-display-block" + class="gl-my-5 gl-mx-5 gl-border-gray-100 gl-display-none gl-md-display-block" /> <status-filter v-if="showStatusFilter" /> <confidentiality-filter v-if="showConfidentialityFilter" /> diff --git a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue index 7a03306e2f9..3c280a5d696 100644 --- a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue +++ b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue @@ -1,13 +1,10 @@ <script> import { GlNav, GlNavItem, GlIcon } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; -import { formatNumber, s__ } from '~/locale'; +import { s__ } from '~/locale'; import Tracking from '~/tracking'; -import { - NAV_LINK_DEFAULT_CLASSES, - NUMBER_FORMATING_OPTIONS, - NAV_LINK_COUNT_DEFAULT_CLASSES, -} from '../constants'; +import { NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES } from '../constants'; +import { formatSearchResultCount } from '../../store/utils'; export default { name: 'ScopeNavigation', @@ -29,11 +26,7 @@ export default { methods: { ...mapActions(['fetchSidebarCount']), showFormatedCount(count) { - if (!count) { - return '0'; - } - const countNumber = parseInt(count.replace(/,/g, ''), 10); - return formatNumber(countNumber, NUMBER_FORMATING_OPTIONS); + return formatSearchResultCount(count); }, isCountOverLimit(count) { return count.includes('+'); @@ -82,6 +75,6 @@ export default { </span> </gl-nav-item> </gl-nav> - <hr class="gl-mt-5 gl-mb-0 gl-border-gray-100 gl-md-display-none" /> + <hr class="gl-mt-5 gl-mx-5 gl-mb-0 gl-border-gray-100 gl-md-display-none" /> </nav> </template> diff --git a/app/assets/javascripts/search/sidebar/components/status_filter.vue b/app/assets/javascripts/search/sidebar/components/status_filter.vue index eaf7d95822a..4da96a41ef7 100644 --- a/app/assets/javascripts/search/sidebar/components/status_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/status_filter.vue @@ -21,6 +21,6 @@ export default { <template> <div> <radio-filter :class="ffBasedXPadding" :filter-data="$options.stateFilterData" /> - <hr class="gl-my-5 gl-border-gray-100" /> + <hr class="gl-my-5 gl-mx-5 gl-border-gray-100" /> </div> </template> diff --git a/app/assets/javascripts/search/sidebar/constants/index.js b/app/assets/javascripts/search/sidebar/constants/index.js index a9c031f91a4..19b1ad0905b 100644 --- a/app/assets/javascripts/search/sidebar/constants/index.js +++ b/app/assets/javascripts/search/sidebar/constants/index.js @@ -1,13 +1,16 @@ export const SCOPE_ISSUES = 'issues'; export const SCOPE_MERGE_REQUESTS = 'merge_requests'; - -export const NUMBER_FORMATING_OPTIONS = { notation: 'compact', compactDisplay: 'short' }; -export const NAV_LINK_DEFAULT_CLASSES = [ +export const SCOPE_BLOB = 'blobs'; +export const LABEL_DEFAULT_CLASSES = [ 'gl-display-flex', 'gl-flex-direction-row', 'gl-flex-wrap-nowrap', - 'gl-justify-content-space-between', 'gl-text-gray-900', ]; - +export const NAV_LINK_DEFAULT_CLASSES = [ + ...LABEL_DEFAULT_CLASSES, + 'gl-justify-content-space-between', +]; export const NAV_LINK_COUNT_DEFAULT_CLASSES = ['gl-font-sm', 'gl-font-weight-normal']; +export const HR_DEFAULT_CLASSES = ['gl-my-5', 'gl-mx-5', 'gl-border-gray-100']; +export const ONLY_SHOW_MD = ['gl-display-none', 'gl-md-display-block']; diff --git a/app/assets/javascripts/search/sidebar/index.js b/app/assets/javascripts/search/sidebar/index.js index c6b1257c4ef..415f6f7454c 100644 --- a/app/assets/javascripts/search/sidebar/index.js +++ b/app/assets/javascripts/search/sidebar/index.js @@ -6,11 +6,11 @@ Vue.use(Translate); export const sidebarInitState = () => { const el = document.getElementById('js-search-sidebar'); - if (!el) return {}; - const { navigation } = el.dataset; - return JSON.parse(navigation); + const { navigationJson } = el.dataset; + const navigationJsonParsed = JSON.parse(navigationJson); + return { navigationJsonParsed }; }; export const initSidebar = (store) => { diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js index 678bd82c7a6..e4f67f624ca 100644 --- a/app/assets/javascripts/search/store/constants.js +++ b/app/assets/javascripts/search/store/constants.js @@ -10,3 +10,5 @@ export const GROUPS_LOCAL_STORAGE_KEY = 'global-search-frequent-groups'; export const PROJECTS_LOCAL_STORAGE_KEY = 'global-search-frequent-projects'; export const SIDEBAR_PARAMS = [stateFilterData.filterParam, confidentialFilterData.filterParam]; + +export const NUMBER_FORMATING_OPTIONS = { notation: 'compact', compactDisplay: 'short' }; diff --git a/app/assets/javascripts/search/store/utils.js b/app/assets/javascripts/search/store/utils.js index f8198017bf8..acb99c60426 100644 --- a/app/assets/javascripts/search/store/utils.js +++ b/app/assets/javascripts/search/store/utils.js @@ -1,5 +1,12 @@ import AccessorUtilities from '~/lib/utils/accessor'; -import { MAX_FREQUENT_ITEMS, MAX_FREQUENCY, SIDEBAR_PARAMS } from './constants'; +import { formatNumber } from '~/locale'; +import { joinPaths } from '~/lib/utils/url_utility'; +import { + MAX_FREQUENT_ITEMS, + MAX_FREQUENCY, + SIDEBAR_PARAMS, + NUMBER_FORMATING_OPTIONS, +} from './constants'; function extractKeys(object, keyList) { return Object.fromEntries(keyList.map((key) => [key, object[key]])); @@ -90,3 +97,18 @@ export const isSidebarDirty = (currentQuery, urlQuery) => { return userAddedParam || userChangedExistingParam; }); }; + +export const formatSearchResultCount = (count) => { + if (!count) { + return '0'; + } + + const countNumber = typeof count === 'string' ? parseInt(count.replace(/,/g, ''), 10) : count; + return formatNumber(countNumber, NUMBER_FORMATING_OPTIONS); +}; + +export const getAggregationsUrl = () => { + const currentUrl = new URL(window.location.href); + currentUrl.pathname = joinPaths('/search', 'aggregations'); + return currentUrl.toString(); +}; diff --git a/app/assets/javascripts/search/topbar/constants.js b/app/assets/javascripts/search/topbar/constants.js index 121c15199dd..5b1c5819f2b 100644 --- a/app/assets/javascripts/search/topbar/constants.js +++ b/app/assets/javascripts/search/topbar/constants.js @@ -20,4 +20,4 @@ export const PROJECT_DATA = { fullName: 'name_with_namespace', }; -export const SYNTAX_OPTIONS_DOCUMENT = 'drawers/user/search/advanced_search.md'; +export const SYNTAX_OPTIONS_DOCUMENT = 'drawers/drawers/advanced_search_syntax.md'; diff --git a/app/assets/javascripts/self_monitor/store/actions.js b/app/assets/javascripts/self_monitor/store/actions.js index 7198dbe8b04..d94fd77dd42 100644 --- a/app/assets/javascripts/self_monitor/store/actions.js +++ b/app/assets/javascripts/self_monitor/store/actions.js @@ -1,6 +1,6 @@ import axios from '~/lib/utils/axios_utils'; import { backOff } from '~/lib/utils/common_utils'; -import statusCodes, { HTTP_STATUS_ACCEPTED } from '~/lib/utils/http_status'; +import { HTTP_STATUS_ACCEPTED, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { __, s__ } from '~/locale'; import * as types from './mutation_types'; @@ -43,7 +43,7 @@ export const requestCreateProject = ({ dispatch, state, commit }) => { export const requestCreateProjectStatus = ({ dispatch, state }, jobId) => { backOffRequest(() => axios.get(state.createProjectStatusEndpoint, { params: { job_id: jobId } })) .then((resp) => { - if (resp.status === statusCodes.OK) { + if (resp.status === HTTP_STATUS_OK) { dispatch('requestCreateProjectSuccess', resp.data); } }) @@ -95,7 +95,7 @@ export const requestDeleteProject = ({ dispatch, state, commit }) => { export const requestDeleteProjectStatus = ({ dispatch, state }, jobId) => { backOffRequest(() => axios.get(state.deleteProjectStatusEndpoint, { params: { job_id: jobId } })) .then((resp) => { - if (resp.status === statusCodes.OK) { + if (resp.status === HTTP_STATUS_OK) { dispatch('requestDeleteProjectSuccess', resp.data); } }) diff --git a/app/assets/javascripts/set_status_modal/set_status_form.vue b/app/assets/javascripts/set_status_modal/set_status_form.vue index dd27a12cbee..c96189c7cae 100644 --- a/app/assets/javascripts/set_status_modal/set_status_form.vue +++ b/app/assets/javascripts/set_status_modal/set_status_form.vue @@ -8,7 +8,6 @@ import { GlFormInputGroup, GlDropdown, GlDropdownItem, - GlSprintf, GlFormGroup, } from '@gitlab/ui'; import $ from 'jquery'; @@ -16,7 +15,8 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; import * as Emoji from '~/emoji'; import { s__ } from '~/locale'; -import { TIME_RANGES_WITH_NEVER, AVAILABILITY_STATUS } from './constants'; +import { formatDate, newDate, nSecondsAfter, isToday } from '~/lib/utils/datetime_utility'; +import { TIME_RANGES_WITH_NEVER, AVAILABILITY_STATUS, NEVER_TIME_RANGE } from './constants'; export default { components: { @@ -27,7 +27,6 @@ export default { GlFormInputGroup, GlDropdown, GlDropdownItem, - GlSprintf, GlFormGroup, EmojiPicker: () => import('~/emoji/components/picker.vue'), }, @@ -56,7 +55,7 @@ export default { clearStatusAfter: { type: Object, required: false, - default: () => ({}), + default: null, }, currentClearStatusAfter: { type: String, @@ -80,6 +79,21 @@ export default { noEmoji() { return this.emojiTag === ''; }, + clearStatusAfterDropdownText() { + if (this.clearStatusAfter === null && this.currentClearStatusAfter.length) { + return this.formatClearStatusAfterDate(new Date(this.currentClearStatusAfter)); + } + + if (this.clearStatusAfter?.duration?.seconds) { + const clearStatusAfterDate = nSecondsAfter( + newDate(), + this.clearStatusAfter.duration.seconds, + ); + return this.formatClearStatusAfterDate(clearStatusAfterDate); + } + + return NEVER_TIME_RANGE.label; + }, }, mounted() { this.setupEmojiListAndAutocomplete(); @@ -124,6 +138,13 @@ export default { this.$emit('message-input', ''); this.clearEmoji(); }, + formatClearStatusAfterDate(date) { + if (isToday(date)) { + return formatDate(date, 'h:MMtt'); + } + + return formatDate(date, 'mmm d, yyyy h:MMtt'); + }, }, TIME_RANGES_WITH_NEVER, AVAILABILITY_STATUS, @@ -202,7 +223,7 @@ export default { <gl-form-group :label="$options.i18n.clearStatusAfterDropdownLabel" class="gl-mb-0"> <gl-dropdown block - :text="clearStatusAfter.label" + :text="clearStatusAfterDropdownText" data-testid="clear-status-at-dropdown" toggle-class="gl-mb-0 gl-form-input-md" > @@ -214,14 +235,6 @@ export default { >{{ after.label }}</gl-dropdown-item > </gl-dropdown> - - <template v-if="currentClearStatusAfter.length" #description> - <span data-testid="clear-status-at-message"> - <gl-sprintf :message="$options.i18n.clearStatusAfterMessage"> - <template #date>{{ currentClearStatusAfter }}</template> - </gl-sprintf> - </span> - </template> </gl-form-group> </div> </template> diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue index 5becc03646e..e7d028e8d23 100644 --- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue +++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue @@ -6,8 +6,8 @@ import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { s__ } from '~/locale'; import { updateUserStatus } from '~/rest_api'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { isUserBusy } from './utils'; -import { NEVER_TIME_RANGE, AVAILABILITY_STATUS } from './constants'; +import { isUserBusy, computedClearStatusAfterValue } from './utils'; +import { AVAILABILITY_STATUS } from './constants'; import SetStatusForm from './set_status_form.vue'; Vue.use(GlToast); @@ -53,9 +53,17 @@ export default { message: this.currentMessage, modalId: 'set-user-status-modal', availability: isUserBusy(this.currentAvailability), - clearStatusAfter: NEVER_TIME_RANGE, + clearStatusAfter: null, }; }, + computed: { + shouldIncludeClearStatusAfterInApiRequest() { + return this.clearStatusAfter !== null; + }, + clearStatusAfterApiRequestValue() { + return computedClearStatusAfterValue(this.clearStatusAfter); + }, + }, mounted() { this.$root.$emit(BV_SHOW_MODAL, this.modalId); }, @@ -70,14 +78,21 @@ export default { this.setStatus(); }, setStatus() { - const { emoji, message, availability, clearStatusAfter } = this; + const { + emoji, + message, + availability, + shouldIncludeClearStatusAfterInApiRequest, + clearStatusAfterApiRequestValue, + } = this; updateUserStatus({ emoji, message, availability: availability ? AVAILABILITY_STATUS.BUSY : AVAILABILITY_STATUS.NOT_SET, - clearStatusAfter: - clearStatusAfter.label === NEVER_TIME_RANGE.label ? null : clearStatusAfter.shortcut, + ...(shouldIncludeClearStatusAfterInApiRequest + ? { clearStatusAfter: clearStatusAfterApiRequestValue } + : {}), }) .then(this.onUpdateSuccess) .catch(this.onUpdateFail); diff --git a/app/assets/javascripts/set_status_modal/user_profile_set_status_wrapper.vue b/app/assets/javascripts/set_status_modal/user_profile_set_status_wrapper.vue index c709611e13d..48693485116 100644 --- a/app/assets/javascripts/set_status_modal/user_profile_set_status_wrapper.vue +++ b/app/assets/javascripts/set_status_modal/user_profile_set_status_wrapper.vue @@ -1,9 +1,7 @@ <script> -import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; -import dateFormat from '~/lib/dateformat'; import SetStatusForm from './set_status_form.vue'; -import { isUserBusy } from './utils'; -import { NEVER_TIME_RANGE, AVAILABILITY_STATUS } from './constants'; +import { isUserBusy, computedClearStatusAfterValue } from './utils'; +import { AVAILABILITY_STATUS } from './constants'; export default { components: { SetStatusForm }, @@ -13,15 +11,16 @@ export default { emoji: this.fields.emoji.value, message: this.fields.message.value, availability: isUserBusy(this.fields.availability.value), - clearStatusAfter: NEVER_TIME_RANGE, + clearStatusAfter: null, currentClearStatusAfter: this.fields.clearStatusAfter.value, }; }, computed: { - clearStatusAfterInputValue() { - return this.clearStatusAfter.label === NEVER_TIME_RANGE.label - ? null - : this.clearStatusAfter.shortcut; + showClearStatusAfterHiddenInput() { + return this.clearStatusAfter !== null; + }, + clearStatusAfterHiddenInputValue() { + return computedClearStatusAfterValue(this.clearStatusAfter); }, availabilityInputValue() { return this.availability @@ -29,18 +28,6 @@ export default { : this.$options.AVAILABILITY_STATUS.NOT_SET; }, }, - mounted() { - this.$options.formEl = document.querySelector('form.js-edit-user'); - - if (!this.$options.formEl) return; - - this.$options.formEl.addEventListener('ajax:success', this.handleFormSuccess); - }, - beforeDestroy() { - if (!this.$options.formEl) return; - - this.$options.formEl.removeEventListener('ajax:success', this.handleFormSuccess); - }, methods: { handleMessageInput(value) { this.message = value; @@ -54,24 +41,6 @@ export default { handleAvailabilityInput(value) { this.availability = value; }, - handleFormSuccess() { - if (!this.clearStatusAfter?.duration?.seconds) { - this.currentClearStatusAfter = ''; - - return; - } - - const now = new Date(); - const currentClearStatusAfterDate = new Date( - now.getTime() + secondsToMilliseconds(this.clearStatusAfter.duration.seconds), - ); - - this.currentClearStatusAfter = dateFormat( - currentClearStatusAfterDate, - "UTC:yyyy-mm-dd HH:MM:ss 'UTC'", - ); - this.clearStatusAfter = NEVER_TIME_RANGE; - }, }, AVAILABILITY_STATUS, formEl: null, @@ -83,7 +52,12 @@ export default { <input :value="emoji" type="hidden" :name="fields.emoji.name" /> <input :value="message" type="hidden" :name="fields.message.name" /> <input :value="availabilityInputValue" type="hidden" :name="fields.availability.name" /> - <input :value="clearStatusAfterInputValue" type="hidden" :name="fields.clearStatusAfter.name" /> + <input + v-if="showClearStatusAfterHiddenInput" + :value="clearStatusAfterHiddenInputValue" + type="hidden" + :name="fields.clearStatusAfter.name" + /> <set-status-form default-emoji="speech_balloon" :emoji="emoji" diff --git a/app/assets/javascripts/set_status_modal/utils.js b/app/assets/javascripts/set_status_modal/utils.js index 950091195d2..11e47fdf956 100644 --- a/app/assets/javascripts/set_status_modal/utils.js +++ b/app/assets/javascripts/set_status_modal/utils.js @@ -1,4 +1,12 @@ -import { AVAILABILITY_STATUS } from './constants'; +import { AVAILABILITY_STATUS, NEVER_TIME_RANGE } from './constants'; export const isUserBusy = (status = '') => Boolean(status.length && status.toLowerCase().trim() === AVAILABILITY_STATUS.BUSY); + +export const computedClearStatusAfterValue = (value) => { + if (value === null || value.name === NEVER_TIME_RANGE.name) { + return null; + } + + return value.shortcut; +}; diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue index 2a78db352d7..04c62c99a11 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue @@ -1,5 +1,4 @@ <script> -import $ from 'jquery'; import Vue from 'vue'; import Vuex, { mapState, mapActions, mapGetters } from 'vuex'; import { isInViewport } from '~/lib/utils/common_utils'; @@ -260,8 +259,8 @@ export default { target?.parentElement?.classList.contains(className), ); - const hasExceptionParent = ['.js-btn-back', '.js-labels-list'].some( - (className) => $(target).parents(className).length, + const hasExceptionParent = ['.js-btn-back', '.js-labels-list'].some((className) => + target?.closest(className), ); const isInDropdownButtonCollapsed = this.$refs.dropdownButtonCollapsed?.$el.contains(target); diff --git a/app/assets/javascripts/super_sidebar/components/bottom_bar.vue b/app/assets/javascripts/super_sidebar/components/bottom_bar.vue new file mode 100644 index 00000000000..fea29458f45 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/bottom_bar.vue @@ -0,0 +1,24 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlIcon, + }, + i18n: { + help: __('Help'), + new: __('New'), + }, +}; +</script> + +<template> + <div class="bottom-links gl-p-3"> + <a href="#" class="gl-text-black-normal" + ><gl-icon name="question-o" class="gl-mr-3 gl-text-gray-300 gl-text-black-normal!" />{{ + $options.i18n.help + }}</a + > + </div> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher.vue b/app/assets/javascripts/super_sidebar/components/context_switcher.vue new file mode 100644 index 00000000000..f1ddb8290a0 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/context_switcher.vue @@ -0,0 +1,83 @@ +<script> +import { GlAvatar, GlSearchBoxByType } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { contextSwitcherItems } from '../mock_data'; +import NavItem from './nav_item.vue'; + +export default { + components: { + GlAvatar, + GlSearchBoxByType, + NavItem, + }, + i18n: { + contextNavigation: s__('Navigation|Context navigation'), + switchTo: s__('Navigation|Switch to...'), + recentProjects: s__('Navigation|Recent projects'), + recentGroups: s__('Navigation|Recent groups'), + }, + contextSwitcherItems, + viewAllProjectsItem: { + title: s__('Navigation|View all projects'), + link: '/projects', + icon: 'project', + }, + viewAllGroupsItem: { + title: s__('Navigation|View all groups'), + link: '/groups', + icon: 'group', + }, +}; +</script> + +<template> + <div> + <gl-search-box-by-type /> + <nav :aria-label="$options.i18n.contextNavigation"> + <ul class="gl-p-0 gl-list-style-none"> + <li> + <div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3"> + {{ $options.i18n.switchTo }} + </div> + <ul :aria-label="$options.i18n.switchTo" class="gl-p-0"> + <nav-item :item="$options.contextSwitcherItems.yourWork" /> + </ul> + </li> + <li> + <div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3"> + {{ $options.i18n.recentProjects }} + </div> + <ul :aria-label="$options.i18n.recentProjects" class="gl-p-0"> + <nav-item + v-for="project in $options.contextSwitcherItems.recentProjects" + :key="project.title" + :item="project" + > + <template #icon> + <gl-avatar shape="rect" :size="32" :src="project.avatar" /> + </template> + </nav-item> + <nav-item :item="$options.viewAllProjectsItem" /> + </ul> + </li> + <li> + <div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3"> + {{ $options.i18n.recentGroups }} + </div> + <ul :aria-label="$options.i18n.recentGroups" class="gl-p-0"> + <nav-item + v-for="project in $options.contextSwitcherItems.recentGroups" + :key="project.title" + :item="project" + > + <template #icon> + <gl-avatar shape="rect" :size="32" :src="project.avatar" /> + </template> + </nav-item> + <nav-item :item="$options.viewAllGroupsItem" /> + </ul> + </li> + </ul> + </nav> + </div> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue new file mode 100644 index 00000000000..b6f058f7aee --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue @@ -0,0 +1,45 @@ +<script> +import { GlTruncate, GlAvatar, GlCollapseToggleDirective, GlIcon } from '@gitlab/ui'; + +export default { + components: { + GlTruncate, + GlAvatar, + GlIcon, + }, + directives: { + CollapseToggle: GlCollapseToggleDirective, + }, + props: { + context: { + type: Object, + required: true, + }, + expanded: { + type: Boolean, + required: true, + }, + }, + computed: { + collapseIcon() { + return this.expanded ? 'chevron-up' : 'chevron-down'; + }, + }, +}; +</script> + +<template> + <button + v-collapse-toggle.context-switcher + type="button" + class="context-switcher-toggle gl-bg-transparent gl-border-0 border-top border-bottom gl-border-gray-a-08 gl-box-shadow-none gl-display-flex gl-align-items-center gl-font-weight-bold gl-w-full gl-pl-3 gl-pr-5 gl-h-8" + > + <gl-avatar :size="32" shape="rect" :src="context.avatar" class="gl-mr-3" /> + <div class="gl-overflow-auto"> + <gl-truncate :text="context.title" /> + </div> + <span class="gl-flex-grow-1 gl-text-right"> + <gl-icon :name="collapseIcon" /> + </span> + </button> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/counter.vue b/app/assets/javascripts/super_sidebar/components/counter.vue new file mode 100644 index 00000000000..d790e61ca31 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/counter.vue @@ -0,0 +1,48 @@ +<script> +import { GlIcon } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + }, + props: { + count: { + type: Number, + required: true, + }, + href: { + type: String, + required: false, + default: '', + }, + icon: { + type: String, + required: true, + }, + label: { + type: String, + required: true, + }, + }, + computed: { + ariaLabel() { + return `${this.label} ${this.count}`; + }, + component() { + return this.href ? 'a' : 'button'; + }, + }, +}; +</script> + +<template> + <component + :is="component" + :aria-label="ariaLabel" + :href="href" + class="counter gl-relative gl-display-inline-block gl-flex-grow-1 gl-text-center gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-black-normal gl-border gl-border-gray-a-08 gl-font-sm gl-font-weight-bold" + > + <gl-icon aria-hidden="true" :name="icon" /> + <span aria-hidden="true">{{ count }}</span> + </component> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue new file mode 100644 index 00000000000..4fd6918fd6f --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue @@ -0,0 +1,37 @@ +<script> +import { GlIcon } from '@gitlab/ui'; + +export default { + name: 'NavItem', + components: { + GlIcon, + }, + props: { + item: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <li> + <a + :href="item.link" + class="gl-display-flex gl-pl-3 gl-py-3 gl-line-height-normal gl-text-black-normal gl-hover-bg-t-gray-a-08" + > + <div class="gl-mr-3"> + <slot name="icon"> + <gl-icon v-if="item.icon" :name="item.icon" /> + </slot> + </div> + <div class="gl-pr-3"> + {{ item.title }} + <div v-if="item.subtitle" class="gl-font-sm gl-text-gray-500 gl-mt-1"> + {{ item.subtitle }} + </div> + </div> + </a> + </li> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue new file mode 100644 index 00000000000..e2eac64f5ad --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue @@ -0,0 +1,50 @@ +<script> +import { GlCollapse } from '@gitlab/ui'; +import { context } from '../mock_data'; +import UserBar from './user_bar.vue'; +import ContextSwitcherToggle from './context_switcher_toggle.vue'; +import ContextSwitcher from './context_switcher.vue'; +import BottomBar from './bottom_bar.vue'; + +export default { + context, + components: { + GlCollapse, + UserBar, + ContextSwitcherToggle, + ContextSwitcher, + BottomBar, + }, + props: { + sidebarData: { + type: Object, + required: true, + }, + }, + data() { + return { + contextSwitcherOpened: false, + }; + }, +}; +</script> + +<template> + <aside + class="super-sidebar gl-fixed gl-bottom-0 gl-left-0 gl-display-flex gl-flex-direction-column gl-bg-gray-10 gl-border-r gl-border-gray-a-08 gl-z-index-9999" + data-testid="super-sidebar" + > + <user-bar :sidebar-data="sidebarData" /> + <div class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-overflow-hidden"> + <div class="gl-flex-grow-1 gl-overflow-auto"> + <context-switcher-toggle :context="$options.context" :expanded="contextSwitcherOpened" /> + <gl-collapse id="context-switcher" v-model="contextSwitcherOpened"> + <context-switcher /> + </gl-collapse> + </div> + <div class="gl-px-3"> + <bottom-bar /> + </div> + </div> + </aside> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue new file mode 100644 index 00000000000..7ee1776bf07 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue @@ -0,0 +1,77 @@ +<script> +import { GlAvatar, GlDropdown, GlIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; +import logo from '../../../../views/shared/_logo.svg'; +import Counter from './counter.vue'; + +export default { + logo, + components: { + GlAvatar, + GlDropdown, + GlIcon, + NewNavToggle, + Counter, + }, + i18n: { + issues: __('Issues'), + mergeRequests: __('Merge requests'), + todoList: __('To-Do list'), + }, + directives: { + SafeHtml, + }, + inject: ['rootPath', 'toggleNewNavEndpoint'], + props: { + sidebarData: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div class="user-bar"> + <div class="gl-display-flex gl-align-items-center gl-px-3 gl-py-2 gl-gap-3"> + <div class="gl-flex-grow-1"> + <a v-safe-html="$options.logo" :href="rootPath"></a> + </div> + <gl-dropdown variant="link" no-caret> + <template #button-content> + <gl-icon name="plus" class="gl-vertical-align-middle gl-text-black-normal" /> + </template> + </gl-dropdown> + <button class="gl-border-none"> + <gl-icon name="search" class="gl-vertical-align-middle" /> + </button> + <gl-dropdown data-testid="user-dropdown" variant="link" no-caret> + <template #button-content> + <gl-avatar :entity-name="sidebarData.name" :src="sidebarData.avatar_url" :size="32" /> + </template> + <new-nav-toggle :endpoint="toggleNewNavEndpoint" enabled /> + </gl-dropdown> + </div> + <div class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2"> + <counter + icon="issues" + :count="sidebarData.assigned_open_issues_count" + :href="sidebarData.issues_dashboard_path" + :label="$options.i18n.issues" + /> + <counter + icon="merge-request-open" + :count="sidebarData.assigned_open_merge_requests_count" + :label="$options.i18n.mergeRequests" + /> + <counter + icon="todo-done" + :count="sidebarData.todos_pending_count" + href="/dashboard/todos" + :label="$options.i18n.todoList" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/super_sidebar/mock_data.js b/app/assets/javascripts/super_sidebar/mock_data.js new file mode 100644 index 00000000000..0d1ac006df7 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/mock_data.js @@ -0,0 +1,59 @@ +import { s__ } from '~/locale'; + +export const context = { + title: 'Typeahead.js', + link: '/', + avatar: 'https://gitlab.com/uploads/-/system/project/avatar/278964/project_avatar.png?width=32', +}; + +export const contextSwitcherItems = { + yourWork: { title: s__('Navigation|Your work'), link: '/', icon: 'work' }, + recentProjects: [ + { + // eslint-disable-next-line @gitlab/require-i18n-strings + title: 'Orange', + subtitle: 'tropical-tree', + link: '/tropical-tree', + avatar: + 'https://gitlab.com/uploads/-/system/project/avatar/4456656/pajamas-logo.png?width=64', + }, + { + // eslint-disable-next-line @gitlab/require-i18n-strings + title: 'Lemon', + subtitle: 'tropical-tree', + link: '/tropical-tree', + avatar: 'https://gitlab.com/uploads/-/system/project/avatar/7071551/GitLab_UI.png?width=64', + }, + { + // eslint-disable-next-line @gitlab/require-i18n-strings + title: 'Coconut', + subtitle: 'tropical-tree', + link: '/tropical-tree', + avatar: + 'https://gitlab.com/uploads/-/system/project/avatar/4149988/SVGs_project.png?width=64', + }, + ], + recentGroups: [ + { + title: 'Developer Evangelism at GitLab', + subtitle: 'tropical-tree', + link: '/tropical-tree', + avatar: + 'https://gitlab.com/uploads/-/system/group/avatar/10087220/rainbow_tanuki.jpg?width=64', + }, + { + title: 'security-products', + subtitle: 'tropical-tree', + link: '/tropical-tree', + avatar: + 'https://gitlab.com/uploads/-/system/group/avatar/11932235/gitlab-icon-rgb.png?width=64', + }, + { + title: 'Tanuki-Workshops', + subtitle: 'tropical-tree', + link: '/tropical-tree', + avatar: + 'https://gitlab.com/uploads/-/system/group/avatar/5085244/Screenshot_2019-04-29_at_16.13.07.png?width=64', + }, + ], +}; diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js new file mode 100644 index 00000000000..b9c7073df8c --- /dev/null +++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js @@ -0,0 +1,26 @@ +import Vue from 'vue'; +import SuperSidebar from './components/super_sidebar.vue'; + +export const initSuperSidebar = () => { + const el = document.querySelector('.js-super-sidebar'); + + if (!el) return false; + + const { rootPath, sidebar, toggleNewNavEndpoint } = el.dataset; + + return new Vue({ + el, + name: 'SuperSidebarRoot', + provide: { + rootPath, + toggleNewNavEndpoint, + }, + render(h) { + return h(SuperSidebar, { + props: { + sidebarData: JSON.parse(sidebar), + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/syntax_highlight.js b/app/assets/javascripts/syntax_highlight.js index 96b6e78c668..cb2bf24abc7 100644 --- a/app/assets/javascripts/syntax_highlight.js +++ b/app/assets/javascripts/syntax_highlight.js @@ -11,7 +11,7 @@ // export default function syntaxHighlight($els = null) { - if (!$els) return; + if (!$els || $els.length === 0) return; const els = $els.get ? $els.get() : $els; const handler = (el) => { diff --git a/app/assets/javascripts/terraform/components/states_table.vue b/app/assets/javascripts/terraform/components/states_table.vue index b19f92aaeb4..c88c528a632 100644 --- a/app/assets/javascripts/terraform/components/states_table.vue +++ b/app/assets/javascripts/terraform/components/states_table.vue @@ -11,14 +11,14 @@ import { } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { s__, sprintf } from '~/locale'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import StateActions from './states_table_actions.vue'; export default { components: { - CiBadge, + CiBadgeLink, GlAlert, GlBadge, GlLink, @@ -198,7 +198,7 @@ export default { :id="`terraformJobStatusContainer${item.name}`" class="gl-my-2" > - <ci-badge + <ci-badge-link :id="`terraformJobStatus${item.name}`" :status="pipelineDetailedStatus(item)" class="gl-py-1" diff --git a/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue b/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue new file mode 100644 index 00000000000..94bc15fa0d0 --- /dev/null +++ b/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue @@ -0,0 +1,134 @@ +<script> +import { GlAlert, GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { sprintf } from '~/locale'; +import { updateRepositorySize } from '~/api/projects_api'; +import { + ERROR_MESSAGE, + LEARN_MORE_LABEL, + USAGE_QUOTAS_LABEL, + TOTAL_USAGE_TITLE, + TOTAL_USAGE_SUBTITLE, + TOTAL_USAGE_DEFAULT_TEXT, + HELP_LINK_ARIA_LABEL, + RECALCULATE_REPOSITORY_LABEL, + projectContainerRegistryPopoverContent, +} from '../constants'; +import getProjectStorageStatistics from '../queries/project_storage.query.graphql'; +import { parseGetProjectStorageResults } from '../utils'; +import UsageGraph from './usage_graph.vue'; +import ProjectStorageDetail from './project_storage_detail.vue'; + +export default { + name: 'ProjectStorageApp', + components: { + GlAlert, + GlButton, + GlLink, + GlLoadingIcon, + UsageGraph, + ProjectStorageDetail, + }, + inject: ['projectPath', 'helpLinks'], + provide: { + containerRegistryPopoverContent: projectContainerRegistryPopoverContent, + }, + apollo: { + project: { + query: getProjectStorageStatistics, + variables() { + return { + fullPath: this.projectPath, + }; + }, + update(data) { + return parseGetProjectStorageResults(data, this.helpLinks); + }, + error() { + this.error = ERROR_MESSAGE; + }, + }, + }, + data() { + return { + project: {}, + error: '', + loadingRecalculateSize: false, + }; + }, + computed: { + totalUsage() { + return this.project?.storage?.totalUsage || TOTAL_USAGE_DEFAULT_TEXT; + }, + storageTypes() { + return this.project?.storage?.storageTypes || []; + }, + }, + methods: { + clearError() { + this.error = ''; + }, + helpLinkAriaLabel(linkTitle) { + return sprintf(HELP_LINK_ARIA_LABEL, { + linkTitle, + }); + }, + async postRecalculateSize() { + const alertEl = document.querySelector('.js-recalculation-started-alert'); + + this.loadingRecalculateSize = true; + + await updateRepositorySize(this.projectPath); + + this.loadingRecalculateSize = false; + alertEl?.classList.remove('gl-display-none'); + }, + }, + LEARN_MORE_LABEL, + USAGE_QUOTAS_LABEL, + TOTAL_USAGE_TITLE, + TOTAL_USAGE_SUBTITLE, + RECALCULATE_REPOSITORY_LABEL, +}; +</script> +<template> + <gl-loading-icon v-if="$apollo.queries.project.loading" class="gl-mt-5" size="lg" /> + <gl-alert v-else-if="error" variant="danger" @dismiss="clearError"> + {{ error }} + </gl-alert> + <div v-else> + <div class="gl-pt-5 gl-px-3"> + <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> + <div> + <p class="gl-m-0 gl-font-lg gl-font-weight-bold">{{ $options.TOTAL_USAGE_TITLE }}</p> + <p class="gl-m-0 gl-text-gray-400"> + {{ $options.TOTAL_USAGE_SUBTITLE }} + <gl-link + :href="helpLinks.usageQuotas" + target="_blank" + :aria-label="helpLinkAriaLabel($options.USAGE_QUOTAS_LABEL)" + data-testid="usage-quotas-help-link" + > + {{ $options.LEARN_MORE_LABEL }} + </gl-link> + </p> + </div> + <p class="gl-m-0 gl-font-size-h-display gl-font-weight-bold" data-testid="total-usage"> + {{ totalUsage }} + </p> + </div> + </div> + <div v-if="project.statistics" class="gl-w-full"> + <usage-graph :root-storage-statistics="project.statistics" :limit="0" /> + </div> + <div class="gl-w-full gl-my-5"> + <gl-button + :loading="loadingRecalculateSize" + category="secondary" + @click="postRecalculateSize" + > + {{ $options.RECALCULATE_REPOSITORY_LABEL }} + </gl-button> + </div> + <project-storage-detail :storage-types="storageTypes" /> + </div> +</template> diff --git a/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue b/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue new file mode 100644 index 00000000000..2b97886e650 --- /dev/null +++ b/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue @@ -0,0 +1,142 @@ +<script> +import { GlIcon, GlLink, GlSprintf, GlTableLite, GlPopover } from '@gitlab/ui'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { thWidthPercent } from '~/lib/utils/table_utility'; +import { sprintf } from '~/locale'; +import { + HELP_LINK_ARIA_LABEL, + PROJECT_TABLE_LABEL_STORAGE_TYPE, + PROJECT_TABLE_LABEL_USAGE, + containerRegistryId, + containerRegistryPopoverId, + uploadsId, + uploadsPopoverId, + uploadsPopoverContent, +} from '../constants'; +import { descendingStorageUsageSort } from '../utils'; +import StorageTypeIcon from './storage_type_icon.vue'; + +export default { + name: 'ProjectStorageDetail', + components: { + GlLink, + GlIcon, + GlTableLite, + GlSprintf, + StorageTypeIcon, + GlPopover, + }, + inject: ['containerRegistryPopoverContent'], + props: { + storageTypes: { + type: Array, + required: true, + }, + }, + computed: { + sizeSortedStorageTypes() { + const warnings = { + [containerRegistryId]: { + popoverId: containerRegistryPopoverId, + popoverContent: this.containerRegistryPopoverContent, + }, + [uploadsId]: { + popoverId: uploadsPopoverId, + popoverContent: this.$options.i18n.uploadsPopoverContent, + }, + }; + + return this.storageTypes + .map((type) => { + const warning = warnings[type.storageType.id] || null; + return { + warning, + ...type, + }; + }) + .sort(descendingStorageUsageSort('value')); + }, + }, + methods: { + helpLinkAriaLabel(linkTitle) { + return sprintf(HELP_LINK_ARIA_LABEL, { + linkTitle, + }); + }, + numberToHumanSize, + }, + projectTableFields: [ + { + key: 'storageType', + label: PROJECT_TABLE_LABEL_STORAGE_TYPE, + thClass: thWidthPercent(90), + }, + { + key: 'value', + label: PROJECT_TABLE_LABEL_USAGE, + thClass: thWidthPercent(10), + }, + ], + i18n: { + uploadsPopoverContent, + }, +}; +</script> +<template> + <gl-table-lite :items="sizeSortedStorageTypes" :fields="$options.projectTableFields"> + <template #cell(storageType)="{ item }"> + <div class="gl-display-flex gl-flex-direction-row"> + <storage-type-icon + :name="item.storageType.id" + :data-testid="`${item.storageType.id}-icon`" + /> + <div> + <p class="gl-font-weight-bold gl-mb-0" :data-testid="`${item.storageType.id}-name`"> + {{ item.storageType.name }} + <gl-link + v-if="item.storageType.helpPath" + :href="item.storageType.helpPath" + target="_blank" + :aria-label="helpLinkAriaLabel(item.storageType.name)" + :data-testid="`${item.storageType.id}-help-link`" + > + <gl-icon name="question" :size="12" /> + </gl-link> + </p> + <p class="gl-mb-0" :data-testid="`${item.storageType.id}-description`"> + {{ item.storageType.description }} + </p> + <p v-if="item.storageType.warningMessage" class="gl-mb-0 gl-font-sm"> + <gl-icon name="warning" :size="12" /> + <gl-sprintf :message="item.storageType.warningMessage"> + <template #warningLink="{ content }"> + <gl-link :href="item.storageType.warningLink" target="_blank" class="gl-font-sm">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </p> + </div> + </div> + </template> + + <template #cell(value)="{ item }"> + {{ numberToHumanSize(item.value, 1) }} + + <template v-if="item.warning"> + <gl-icon + :id="item.warning.popoverId" + name="warning" + class="gl-mt-2 gl-lg-mt-0 gl-lg-ml-2" + /> + <gl-popover + triggers="hover focus" + placement="top" + :target="item.warning.popoverId" + :content="item.warning.popoverContent" + :data-testid="item.warning.popoverId" + /> + </template> + </template> + </gl-table-lite> +</template> diff --git a/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue b/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue new file mode 100644 index 00000000000..bc7cd42df1e --- /dev/null +++ b/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue @@ -0,0 +1,35 @@ +<script> +import { GlIcon } from '@gitlab/ui'; + +export default { + components: { GlIcon }, + props: { + name: { + type: String, + required: false, + default: '', + }, + }, + methods: { + iconName(storageTypeName) { + const defaultStorageTypeIcon = 'disk'; + const storageTypeIconMap = { + lfsObjectsSize: 'doc-image', + snippetsSize: 'snippet', + uploadsSize: 'upload', + repositorySize: 'infrastructure-registry', + packagesSize: 'package', + }; + + return storageTypeIconMap[`${storageTypeName}`] ?? defaultStorageTypeIcon; + }, + }, +}; +</script> +<template> + <span + class="gl-display-inline-flex gl-align-items-flex-start gl-justify-content-center gl-min-w-8 gl-pr-2 gl-pt-1" + > + <gl-icon :name="iconName(name)" :size="16" class="gl-mt-1" /> + </span> +</template> diff --git a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue new file mode 100644 index 00000000000..7e001685060 --- /dev/null +++ b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue @@ -0,0 +1,170 @@ +<script> +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { PROJECT_STORAGE_TYPES } from '../constants'; +import { descendingStorageUsageSort } from '../utils'; + +export default { + components: { + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [glFeatureFlagMixin()], + props: { + rootStorageStatistics: { + required: true, + type: Object, + }, + limit: { + required: true, + type: Number, + }, + }, + computed: { + storageTypes() { + const { + containerRegistrySize, + buildArtifactsSize, + pipelineArtifactsSize, + lfsObjectsSize, + packagesSize, + repositorySize, + storageSize, + wikiSize, + snippetsSize, + uploadsSize, + } = this.rootStorageStatistics; + const artifactsSize = buildArtifactsSize + pipelineArtifactsSize; + + if (storageSize === 0) { + return null; + } + + return [ + { + id: 'repositorySize', + style: this.usageStyle(this.barRatio(repositorySize)), + class: 'gl-bg-data-viz-blue-500', + size: repositorySize, + }, + { + id: 'lfsObjectsSize', + style: this.usageStyle(this.barRatio(lfsObjectsSize)), + class: 'gl-bg-data-viz-orange-600', + size: lfsObjectsSize, + }, + { + id: 'packagesSize', + style: this.usageStyle(this.barRatio(packagesSize)), + class: 'gl-bg-data-viz-aqua-500', + size: packagesSize, + }, + { + id: 'containerRegistrySize', + style: this.usageStyle(this.barRatio(containerRegistrySize)), + class: 'gl-bg-data-viz-aqua-800', + size: containerRegistrySize, + }, + { + id: 'buildArtifactsSize', + style: this.usageStyle(this.barRatio(artifactsSize)), + class: 'gl-bg-data-viz-green-600', + size: artifactsSize, + }, + { + id: 'wikiSize', + style: this.usageStyle(this.barRatio(wikiSize)), + class: 'gl-bg-data-viz-magenta-500', + size: wikiSize, + }, + { + id: 'snippetsSize', + style: this.usageStyle(this.barRatio(snippetsSize)), + class: 'gl-bg-data-viz-orange-800', + size: snippetsSize, + }, + { + id: 'uploadsSize', + style: this.usageStyle(this.barRatio(uploadsSize)), + class: 'gl-bg-data-viz-aqua-700', + size: uploadsSize, + }, + ] + .filter((data) => data.size !== 0) + .sort(descendingStorageUsageSort('size')) + .map((storageType) => { + const storageTypeExtraData = PROJECT_STORAGE_TYPES.find( + (type) => storageType.id === type.id, + ); + const { name, tooltip } = storageTypeExtraData || {}; + + return { + name, + tooltip, + ...storageType, + }; + }); + }, + }, + methods: { + formatSize(size) { + return numberToHumanSize(size); + }, + usageStyle(ratio) { + return { flex: ratio }; + }, + barRatio(size) { + let max = this.rootStorageStatistics.storageSize; + + if (this.limit !== 0 && max <= this.limit) { + max = this.limit; + } + + return size / max; + }, + }, +}; +</script> +<template> + <div v-if="storageTypes" class="gl-display-flex gl-flex-direction-column w-100"> + <div class="gl-h-6 gl-my-5 gl-bg-gray-50 gl-rounded-base gl-display-flex"> + <div + v-for="storageType in storageTypes" + :key="storageType.name" + class="storage-type-usage gl-h-full gl-display-inline-block" + :class="storageType.class" + :style="storageType.style" + data-testid="storage-type-usage" + ></div> + </div> + <div class="row gl-mb-4"> + <div + v-for="storageType in storageTypes" + :key="storageType.name" + class="col-md-auto gl-display-flex gl-align-items-center" + data-testid="storage-type-legend" + data-qa-selector="storage_type_legend" + > + <div class="gl-h-2 gl-w-5 gl-mr-2 gl-display-inline-block" :class="storageType.class"></div> + <span class="gl-mr-2 gl-font-weight-bold gl-font-sm"> + {{ storageType.name }} + </span> + <span class="gl-text-gray-500 gl-font-sm"> + {{ formatSize(storageType.size) }} + </span> + <span + v-if="storageType.tooltip" + v-gl-tooltip + :title="storageType.tooltip" + :aria-label="storageType.tooltip" + class="gl-ml-2" + > + <gl-icon name="question" :size="12" /> + </span> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/usage_quotas/storage/constants.js b/app/assets/javascripts/usage_quotas/storage/constants.js new file mode 100644 index 00000000000..fab18cefc60 --- /dev/null +++ b/app/assets/javascripts/usage_quotas/storage/constants.js @@ -0,0 +1,100 @@ +import { s__, __ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +export const ERROR_MESSAGE = s__( + 'UsageQuota|Something went wrong while fetching project storage statistics', +); +export const LEARN_MORE_LABEL = __('Learn more.'); +export const USAGE_QUOTAS_LABEL = s__('UsageQuota|Usage Quotas'); +export const TOTAL_USAGE_TITLE = s__('UsageQuota|Usage breakdown'); +export const TOTAL_USAGE_SUBTITLE = s__( + 'UsageQuota|Includes artifacts, repositories, wiki, uploads, and other items.', +); +export const TOTAL_USAGE_DEFAULT_TEXT = __('Not applicable.'); +export const HELP_LINK_ARIA_LABEL = s__('UsageQuota|%{linkTitle} help link'); +export const RECALCULATE_REPOSITORY_LABEL = s__('UsageQuota|Recalculate repository usage'); + +export const projectContainerRegistryPopoverContent = s__( + 'UsageQuotas|The project-level storage statistics for the Container Registry are directional only and do not include savings for instance-wide deduplication.', +); + +export const containerRegistryId = 'containerRegistrySize'; +export const containerRegistryPopoverId = 'container-registry-popover'; +export const uploadsId = 'uploadsSize'; +export const uploadsPopoverId = 'uploads-popover'; +export const uploadsPopoverContent = s__( + 'NamespaceStorage|Uploads are not counted in namespace storage quotas.', +); + +export const PROJECT_TABLE_LABEL_PROJECT = __('Project'); +export const PROJECT_TABLE_LABEL_STORAGE_TYPE = s__('UsageQuota|Storage type'); +export const PROJECT_TABLE_LABEL_USAGE = s__('UsageQuota|Usage'); + +export const PROJECT_STORAGE_TYPES = [ + { + id: 'containerRegistrySize', + name: s__('UsageQuota|Container Registry'), + description: s__( + 'UsageQuota|Gitlab-integrated Docker Container Registry for storing Docker Images.', + ), + }, + { + id: 'buildArtifactsSize', + name: s__('UsageQuota|Artifacts'), + description: s__('UsageQuota|Pipeline artifacts and job artifacts, created with CI/CD.'), + tooltip: s__('UsageQuota|Artifacts is a sum of build and pipeline artifacts.'), + }, + { + id: 'lfsObjectsSize', + name: s__('UsageQuota|LFS storage'), + description: s__('UsageQuota|Audio samples, videos, datasets, and graphics.'), + }, + { + id: 'packagesSize', + name: s__('UsageQuota|Packages'), + description: s__('UsageQuota|Code packages and container images.'), + }, + { + id: 'repositorySize', + name: s__('UsageQuota|Repository'), + description: s__('UsageQuota|Git repository.'), + }, + { + id: 'snippetsSize', + name: s__('UsageQuota|Snippets'), + description: s__('UsageQuota|Shared bits of code and text.'), + }, + { + id: 'uploadsSize', + name: s__('UsageQuota|Uploads'), + description: s__('UsageQuota|File attachments and smaller design graphics.'), + }, + { + id: 'wikiSize', + name: s__('UsageQuota|Wiki'), + description: s__('UsageQuota|Wiki content.'), + }, +]; + +export const projectHelpPaths = { + containerRegistry: helpPagePath( + 'user/packages/container_registry/reduce_container_registry_storage', + ), + usageQuotas: helpPagePath('user/usage_quotas'), + usageQuotasNamespaceStorageLimit: helpPagePath('user/usage_quotas', { + anchor: 'namespace-storage-limit', + }), + buildArtifacts: helpPagePath('ci/pipelines/job_artifacts', { + anchor: 'when-job-artifacts-are-deleted', + }), + packages: helpPagePath('user/packages/package_registry/index.md', { + anchor: 'reduce-storage-usage', + }), + repository: helpPagePath('user/project/repository/reducing_the_repo_size_using_git'), + snippets: helpPagePath('user/snippets', { + anchor: 'reduce-snippets-repository-size', + }), + wiki: helpPagePath('administration/wikis/index.md', { + anchor: 'reduce-wiki-repository-size', + }), +}; diff --git a/app/assets/javascripts/usage_quotas/storage/init_project_storage.js b/app/assets/javascripts/usage_quotas/storage/init_project_storage.js new file mode 100644 index 00000000000..00cb274902d --- /dev/null +++ b/app/assets/javascripts/usage_quotas/storage/init_project_storage.js @@ -0,0 +1,34 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { projectHelpPaths as helpLinks } from './constants'; +import ProjectStorageApp from './components/project_storage_app.vue'; + +Vue.use(VueApollo); + +export default (containerId = 'js-project-storage-count-app') => { + const el = document.getElementById(containerId); + + if (!el) { + return false; + } + + const { projectPath } = el.dataset; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el, + apolloProvider, + name: 'ProjectStorageApp', + provide: { + projectPath, + helpLinks, + }, + render(createElement) { + return createElement(ProjectStorageApp); + }, + }); +}; diff --git a/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql b/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql new file mode 100644 index 00000000000..6637e5e0865 --- /dev/null +++ b/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql @@ -0,0 +1,17 @@ +query getProjectStorageStatistics($fullPath: ID!) { + project(fullPath: $fullPath) { + id + statistics { + containerRegistrySize + buildArtifactsSize + pipelineArtifactsSize + lfsObjectsSize + packagesSize + repositorySize + snippetsSize + storageSize + uploadsSize + wikiSize + } + } +} diff --git a/app/assets/javascripts/usage_quotas/storage/utils.js b/app/assets/javascripts/usage_quotas/storage/utils.js new file mode 100644 index 00000000000..443788f650d --- /dev/null +++ b/app/assets/javascripts/usage_quotas/storage/utils.js @@ -0,0 +1,49 @@ +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { PROJECT_STORAGE_TYPES } from './constants'; + +export const getStorageTypesFromProjectStatistics = (projectStatistics, helpLinks = {}) => + PROJECT_STORAGE_TYPES.reduce((types, currentType) => { + const helpPathKey = currentType.id.replace(`Size`, ``); + const helpPath = helpLinks[helpPathKey]; + + return types.concat({ + storageType: { + ...currentType, + helpPath, + }, + value: projectStatistics[currentType.id], + }); + }, []); + +/** + * This method parses the results from `getProjectStorageStatistics` call. + * + * @param {Object} data graphql result + * @returns {Object} + */ +export const parseGetProjectStorageResults = (data, helpLinks) => { + const projectStatistics = data?.project?.statistics; + if (!projectStatistics) { + return {}; + } + const { storageSize } = projectStatistics; + const storageTypes = getStorageTypesFromProjectStatistics(projectStatistics, helpLinks); + + return { + storage: { + totalUsage: numberToHumanSize(storageSize, 1), + storageTypes, + }, + statistics: projectStatistics, + }; +}; + +/** + * Creates a sorting function to sort storage types by usage in the graph and in the table + * + * @param {string} storageUsageKey key storing value of storage usage + * @returns {Function} sorting function + */ +export function descendingStorageUsageSort(storageUsageKey) { + return (a, b) => b[storageUsageKey] - a[storageUsageKey]; +} diff --git a/app/assets/javascripts/users/profile/components/report_abuse_button.vue b/app/assets/javascripts/users/profile/components/report_abuse_button.vue new file mode 100644 index 00000000000..aabb7fde396 --- /dev/null +++ b/app/assets/javascripts/users/profile/components/report_abuse_button.vue @@ -0,0 +1,55 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; + +import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; + +export default { + name: 'ReportAbuseButton', + components: { + GlButton, + AbuseCategorySelector, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + i18n: { + reportAbuse: __('Report abuse to administrator'), + }, + data() { + return { + open: false, + }; + }, + computed: { + buttonTooltipText() { + return this.$options.i18n.reportAbuse; + }, + }, + methods: { + openDrawer() { + this.open = true; + }, + closeDrawer() { + this.open = false; + }, + hideTooltips() { + this.$root.$emit(BV_HIDE_TOOLTIP); + }, + }, +}; +</script> +<template> + <span> + <gl-button + v-gl-tooltip="buttonTooltipText" + category="primary" + :aria-label="buttonTooltipText" + icon="error" + @click="openDrawer" + @mouseout="hideTooltips" + /> + <abuse-category-selector :show-drawer="open" @close-drawer="closeDrawer" /> + </span> +</template> diff --git a/app/assets/javascripts/users/profile/index.js b/app/assets/javascripts/users/profile/index.js new file mode 100644 index 00000000000..37f8e3ac471 --- /dev/null +++ b/app/assets/javascripts/users/profile/index.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import ReportAbuseButton from './components/report_abuse_button.vue'; + +export const initReportAbuse = () => { + const el = document.getElementById('js-report-abuse'); + + if (!el) return false; + + const { reportAbusePath, reportedUserId, reportedFromUrl } = el.dataset; + + return new Vue({ + el, + provide: { reportAbusePath, reportedUserId, reportedFromUrl }, + render(createElement) { + return createElement(ReportAbuseButton); + }, + }); +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue index e8cc9b2eb2a..7cfc9431c2a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -69,7 +69,7 @@ export default { isCollapsible() { if (!this.isLoadingSummary && this.loadingState !== LOADING_STATES.collapsedError) { if (this.shouldCollapse) { - return this.shouldCollapse(); + return this.shouldCollapse(this.collapsedData); } return true; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue index 6475def461a..e435dc56503 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue @@ -1,12 +1,10 @@ <script> -import { GlButton, GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; export default { components: { GlButton, - GlLoadingIcon, - GlIcon, }, props: { title: { @@ -32,7 +30,7 @@ export default { computed: { arrowIconName() { - return this.isCollapsed ? 'chevron-lg-right' : 'chevron-lg-down'; + return this.isCollapsed ? 'chevron-right' : 'chevron-down'; }, ariaLabel() { return this.isCollapsed ? __('Expand') : __('Collapse'); @@ -47,7 +45,7 @@ export default { </script> <template> <div class="mr-widget-extension"> - <div class="d-flex align-items-center pl-3"> + <div class="d-flex align-items-center pl-3 gl-py-3"> <div v-if="hasError" class="ci-widget media"> <div class="media-body"> <span class="gl-font-sm mr-widget-margin-left gl-line-height-24 js-error-state"> @@ -57,16 +55,15 @@ export default { </div> <template v-else> - <button - class="btn-blank btn s32 square" - type="button" + <gl-button + class="gl-mr-3" + size="small" :aria-label="ariaLabel" - :disabled="isLoading" + :loading="isLoading" + :icon="arrowIconName" + category="tertiary" @click="toggleCollapsed" - > - <gl-loading-icon v-if="isLoading" size="sm" /> - <gl-icon v-else :name="arrowIconName" class="js-icon" /> - </button> + /> <template v-if="isCollapsed"> <slot name="header"></slot> <gl-button diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue index c2a3ae361ca..20284c4a3d8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue @@ -19,20 +19,18 @@ export default { }, step3: { label: __('Step 3.'), - help: __( - 'Merge the feature branch into the target branch and fix any conflicts. %{linkStart}How do I fix them?%{linkEnd}', - ), + help: __('Resolve any conflicts. %{linkStart}How do I fix them?%{linkEnd}'), }, step4: { label: __('Step 4.'), - help: __('Push the target branch up to GitLab.'), + help: __('Push the source branch up to GitLab.'), }, }, copyCommands: __('Copy commands'), tip: __( - '%{strongStart}Tip:%{strongEnd} You can also check out merge requests locally. %{linkStart}Learn more.%{linkEnd}', + '%{strongStart}Tip:%{strongEnd} You can also %{linkStart}check out with merge request ID%{linkEnd}.', ), - title: __('Check out, review, and merge locally'), + title: __('Check out, review, and resolve locally'), }, components: { GlModal, @@ -93,21 +91,11 @@ export default { : `git fetch origin\ngit checkout -b ${this.escapedSourceBranch} ${escapedOriginBranch}`; }, mergeInfo2() { - return this.isFork - ? `git fetch origin\ngit checkout ${this.escapedTargetBranch}\ngit merge --no-ff ${this.escapedForkBranch}` - : `git fetch origin\ngit checkout ${this.escapedTargetBranch}\ngit merge --no-ff ${this.escapedSourceBranch}`; - }, - mergeInfo3() { - return this.canMerge - ? `git push origin ${this.escapedTargetBranch}` - : __('Note that pushing to GitLab requires write access to this repository.'); + return `git push origin ${this.escapedSourceBranch}`; }, escapedForkBranch() { return escapeShellString(`${this.sourceProjectPath}-${this.sourceBranch}`); }, - escapedTargetBranch() { - return escapeShellString(this.targetBranch); - }, escapedSourceBranch() { return escapeShellString(this.sourceBranch); }, @@ -145,6 +133,18 @@ export default { class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0" /> </div> + <p v-if="reviewingDocsPath"> + <gl-sprintf data-testid="docs-tip" :message="$options.i18n.tip"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + <template #link="{ content }"> + <gl-link class="gl-display-inline-block" :href="reviewingDocsPath" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </p> <p> <strong> @@ -164,14 +164,6 @@ export default { </template> </gl-sprintf> </p> - <div class="gl-display-flex"> - <pre class="gl-w-full" data-testid="how-to-merge-instructions">{{ mergeInfo2 }}</pre> - <clipboard-button - :text="mergeInfo2" - :title="$options.i18n.copyCommands" - class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0" - /> - </div> <p> <strong> {{ $options.i18n.steps.step4.label }} @@ -179,24 +171,12 @@ export default { {{ $options.i18n.steps.step4.help }} </p> <div class="gl-display-flex"> - <pre class="gl-w-full" data-testid="how-to-merge-instructions">{{ mergeInfo3 }}</pre> + <pre class="gl-w-full" data-testid="how-to-merge-instructions">{{ mergeInfo2 }}</pre> <clipboard-button - :text="mergeInfo3" + :text="mergeInfo2" :title="$options.i18n.copyCommands" class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0" /> </div> - <p v-if="reviewingDocsPath"> - <gl-sprintf data-testid="docs-tip" :message="$options.i18n.tip"> - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - <template #link="{ content }"> - <gl-link class="gl-display-inline-block" :href="reviewingDocsPath" target="_blank">{{ - content - }}</gl-link> - </template> - </gl-sprintf> - </p> </gl-modal> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/report_widget_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/report_widget_container.vue index 2683956e603..ecf08f78f57 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/report_widget_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/report_widget_container.vue @@ -6,7 +6,15 @@ export default { }; }, updated() { - this.hasChildren = this.$scopedSlots.default?.()?.some((c) => c.tag); + this.hasChildren = this.checkSlots(); + }, + mounted() { + this.hasChildren = this.checkSlots(); + }, + methods: { + checkSlots() { + return this.$scopedSlots.default?.()?.some((c) => c.tag); + }, }, }; </script> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue index 9a3555d3e11..f7d6f7b4345 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue @@ -76,17 +76,17 @@ export default { <div :class="{ 'gl-display-flex gl-align-items-center': actions.length, - 'gl-md-display-flex gl-align-items-center': !actions.length, + 'gl-md-display-flex gl-align-items-center gl-flex-wrap gl-gap-3': !actions.length, }" - class="media-body" + class="media-body gl-line-height-24" > <slot></slot> <div :class="{ - 'state-container-action-buttons gl-flex-direction-column gl-flex-wrap gl-justify-content-end': !actions.length, + 'state-container-action-buttons gl-flex-wrap gl-lg-justify-content-end': !actions.length, 'gl-md-pt-0 gl-pt-3': hasActionsSlot, }" - class="gl-display-flex gl-font-size-0 gl-ml-auto gl-gap-3" + class="gl-display-flex gl-font-size-0 gl-gap-3" > <slot name="actions"> <actions v-if="actions.length" :tertiary-buttons="actions" /> 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 8e1b18c63a4..a5d982fe221 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 @@ -88,25 +88,24 @@ export default { </template> <template v-if="!isLoading && !state.shouldBeRebased" #actions> <gl-button - v-if="userPermissions.canMerge" + v-if="showResolveButton" + :href="mr.conflictResolutionPath" size="small" variant="confirm" - category="secondary" - data-testid="merge-locally-button" - class="js-check-out-modal-trigger gl-align-self-start" - :class="{ 'gl-mr-2': showResolveButton }" + class="gl-align-self-start" + data-testid="resolve-conflicts-button" > - {{ s__('mrWidget|Resolve locally') }} + {{ s__('mrWidget|Resolve conflicts') }} </gl-button> <gl-button - v-if="showResolveButton" - :href="mr.conflictResolutionPath" + v-if="userPermissions.canMerge" size="small" variant="confirm" - class="gl-mb-2 gl-md-mb-0 gl-align-self-start" - data-testid="resolve-conflicts-button" + category="secondary" + data-testid="merge-locally-button" + class="js-check-out-modal-trigger gl-align-self-start" > - {{ s__('mrWidget|Resolve conflicts') }} + {{ s__('mrWidget|Resolve locally') }} </gl-button> </template> </state-container> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index 4ae4edf02c3..d687f0346c7 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -179,27 +179,27 @@ export default { </template> <template v-if="!isLoading" #actions> <gl-button - v-if="showRebaseWithoutPipeline" :loading="isMakingRequest" variant="confirm" size="small" - category="secondary" - data-testid="rebase-without-ci-button" - class="gl-align-self-start gl-mr-2" - @click="rebaseWithoutCi" + data-qa-selector="mr_rebase_button" + data-testid="standard-rebase-button" + class="gl-align-self-start" + @click="rebase" > - {{ __('Rebase without pipeline') }} + {{ __('Rebase') }} </gl-button> <gl-button + v-if="showRebaseWithoutPipeline" :loading="isMakingRequest" variant="confirm" size="small" - data-qa-selector="mr_rebase_button" - data-testid="standard-rebase-button" - class="gl-mb-2 gl-md-mb-0 gl-align-self-start" - @click="rebase" + category="secondary" + data-testid="rebase-without-ci-button" + class="gl-align-self-start gl-mr-2" + @click="rebaseWithoutCi" > - {{ __('Rebase') }} + {{ __('Rebase without pipeline') }} </gl-button> </template> </state-container> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue index 01f9b4757a0..211fbba305f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue @@ -1,7 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; import { produce } from 'immer'; -import $ from 'jquery'; import { createAlert } from '~/flash'; import toast from '~/vue_shared/plugins/global_toast'; import { __ } from '~/locale'; @@ -111,7 +110,9 @@ export default { }, }) => { toast(__('Marked as ready. Merging is now allowed.')); - $('.merge-request .detail-page-description .title').text(title); + document.querySelector( + '.merge-request .detail-page-description .title', + ).textContent = title; if (!window.gon?.features?.realtimeMrStatusChange) { eventHub.$emit('MRWidgetUpdateRequested'); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue index 18aa85484ea..5db5f1f8dcf 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue @@ -1,5 +1,11 @@ <script> export default { + components: { + MrSecurityWidget: () => + import( + '~/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue' + ), + }, props: { mr: { type: Object, @@ -8,7 +14,9 @@ export default { }, computed: { widgets() { - return [].filter((w) => w); + return [window.gon?.features?.refactorSecurityExtension && 'MrSecurityWidget'].filter( + (w) => w, + ); }, }, }; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue index cdf35033021..7343c98938c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue @@ -42,7 +42,8 @@ export default { */ value: { type: Object, - required: true, + required: false, + default: () => ({}), }, loadingText: { type: String, @@ -56,7 +57,8 @@ export default { }, fetchCollapsedData: { type: Function, - required: true, + required: false, + default: undefined, }, fetchExpandedData: { type: Function, @@ -119,6 +121,12 @@ export default { required: false, default: null, }, + // When this is provided, the widget will display an error message in the summary section. + hasError: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -138,8 +146,17 @@ export default { summaryStatusIcon() { return this.summaryError ? this.$options.failedStatusIcon : this.statusIconName; }, + hasActionButtons() { + return this.actionButtons.length > 0 || Boolean(this.$scopedSlots['action-buttons']); + }, }, watch: { + hasError: { + handler(newValue) { + this.summaryError = newValue ? this.errorText : null; + }, + immediate: true, + }, isLoading(newValue) { this.$emit('is-loading', newValue); }, @@ -154,7 +171,9 @@ export default { this.telemetryHub?.viewed(); try { - await this.fetch(this.fetchCollapsedData, FETCH_TYPE_COLLAPSED); + if (this.fetchCollapsedData) { + await this.fetch(this.fetchCollapsedData, FETCH_TYPE_COLLAPSED); + } } catch { this.summaryError = this.errorText; } @@ -258,7 +277,7 @@ export default { v-if="helpPopover" icon="information-o" :options="helpPopover.options" - :class="{ 'gl-mr-3': actionButtons.length > 0 }" + :class="{ 'gl-mr-3': hasActionButtons }" > <template v-if="helpPopover.content"> <p @@ -275,12 +294,14 @@ export default { > </template> </help-popover> - <action-buttons - v-if="actionButtons.length > 0" - :widget="widgetName" - :tertiary-buttons="actionButtons" - @clickedAction="onActionClick" - /> + <slot name="action-buttons"> + <action-buttons + v-if="actionButtons.length > 0" + :widget="widgetName" + :tertiary-buttons="actionButtons" + @clickedAction="onActionClick" + /> + </slot> </div> <div v-if="isCollapsible" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue index 543136dc659..b64f9c148d1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue @@ -79,8 +79,11 @@ export default { </script> <template> <div - class="gl-w-full gl-display-flex gl-align-items-baseline" - :class="{ 'gl-border-t gl-py-3 gl-pl-7': level === 2 }" + class="gl-w-full gl-display-flex" + :class="{ + 'gl-border-t gl-py-3 gl-pl-7 gl-align-items-baseline': level === 2, + 'gl-align-items-center': level === 3, + }" > <status-icon v-if="statusIconName && !header" diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/constants.js b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/constants.js index 03af21a5019..26c986884d3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/constants.js @@ -1,4 +1,9 @@ -import { n__, s__, sprintf } from '~/locale'; +import { n__, s__, __, sprintf } from '~/locale'; + +export const codeQualityPrefixes = { + fixed: 'fixed', + new: 'new', +}; export const i18n = { label: s__('ciReport|Code Quality'), @@ -7,25 +12,23 @@ export const i18n = { noChanges: s__(`ciReport|Code Quality hasn't changed.`), prependText: s__(`ciReport|in`), fixed: s__(`ciReport|Fixed`), - pluralReport: (errors) => + findings: (errors, prefix) => sprintf( n__( - '%{strong_start}%{errors}%{strong_end} point', - '%{strong_start}%{errors}%{strong_end} points', + '%{strong_start}%{errors}%{strong_end} %{prefix} finding', + '%{strong_start}%{errors}%{strong_end} %{prefix} findings', errors.length, ), { errors: errors.length, + prefix, }, false, ), - singularReport: (errors) => n__('%d point', '%d points', errors.length), improvementAndDegradationCopy: (improvement, degradation) => - sprintf( - s__(`ciReport|Code Quality improved on ${improvement} and degraded on ${degradation}.`), - ), - improvedCopy: (improvements) => - sprintf(s__(`ciReport|Code Quality improved on ${improvements}.`)), - degradedCopy: (degradations) => - sprintf(s__(`ciReport|Code Quality degraded on ${degradations}.`)), + sprintf(__('Code Quality scans found %{degradation} and %{improvement}.'), { + improvement, + degradation, + }), + singularCopy: (findings) => sprintf(__('Code Quality scans found %{findings}.'), { findings }), }; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js index 394f8979a53..4f9bba1e0cb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js @@ -4,7 +4,7 @@ import { SEVERITY_ICONS_MR_WIDGET } from '~/ci/reports/codequality_report/consta import { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status'; import { parseCodeclimateMetrics } from '~/ci/reports/codequality_report/store/utils/codequality_parser'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; -import { i18n } from './constants'; +import { i18n, codeQualityPrefixes } from './constants'; export default { name: 'WidgetCodeQuality', @@ -12,28 +12,36 @@ export default { props: ['codeQuality', 'blobPath'], i18n, computed: { + shouldCollapse(data) { + const { newErrors, resolvedErrors, parsingInProgress } = data; + if (parsingInProgress || (newErrors.length === 0 && resolvedErrors.length === 0)) { + return false; + } + return true; + }, summary(data) { - const { newErrors, resolvedErrors, errorSummary, parsingInProgress } = data; - + const { newErrors, resolvedErrors, parsingInProgress } = data; if (parsingInProgress) { return i18n.loading; - } else if (errorSummary.errored >= 1 && errorSummary.resolved >= 1) { + } else if (newErrors.length >= 1 && resolvedErrors.length >= 1) { return i18n.improvementAndDegradationCopy( - i18n.pluralReport(resolvedErrors), - i18n.pluralReport(newErrors), + i18n.findings(resolvedErrors, codeQualityPrefixes.fixed), + i18n.findings(newErrors, codeQualityPrefixes.new), ); - } else if (errorSummary.resolved >= 1) { - return i18n.improvedCopy(i18n.singularReport(resolvedErrors)); - } else if (errorSummary.errored >= 1) { - return i18n.degradedCopy(i18n.singularReport(newErrors)); + } else if (resolvedErrors.length >= 1) { + return i18n.singularCopy(i18n.findings(resolvedErrors, codeQualityPrefixes.fixed)); + } else if (newErrors.length >= 1) { + return i18n.singularCopy(i18n.findings(newErrors, codeQualityPrefixes.new)); } return i18n.noChanges; }, statusIcon() { - if (this.collapsedData.errorSummary?.errored >= 1) { + if (this.collapsedData.newErrors.length >= 1) { return EXTENSION_ICONS.warning; + } else if (this.collapsedData.resolvedErrors.length >= 1) { + return EXTENSION_ICONS.success; } - return EXTENSION_ICONS.success; + return EXTENSION_ICONS.neutral; }, }, methods: { @@ -46,8 +54,6 @@ export default { parsingInProgress: status === HTTP_STATUS_NO_CONTENT, resolvedErrors: parseCodeclimateMetrics(data.resolved_errors, this.blobPath.head_path), newErrors: parseCodeclimateMetrics(data.new_errors, this.blobPath.head_path), - existingErrors: parseCodeclimateMetrics(data.existing_errors, this.blobPath.head_path), - errorSummary: data.summary, }, }; }); diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/graphql/security_report_merge_request_download_paths.query.graphql b/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/graphql/security_report_merge_request_download_paths.query.graphql new file mode 100644 index 00000000000..c12e4d1febb --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/graphql/security_report_merge_request_download_paths.query.graphql @@ -0,0 +1,28 @@ +query securityReportsDownloadPaths( + $projectPath: ID! + $iid: String! + $reportTypes: [SecurityReportTypeEnum!] +) { + project(fullPath: $projectPath) { + id + mergeRequest(iid: $iid) { + id + headPipeline { + id + jobs(securityReportTypes: $reportTypes) { + nodes { + id + name + artifacts { + # eslint-disable-next-line @graphql-eslint/require-id-when-available + nodes { + downloadPath + fileType + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue b/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue new file mode 100644 index 00000000000..f0b20adc5cf --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue @@ -0,0 +1,134 @@ +<script> +import { GlDropdown, GlDropdownItem, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import MrWidget from '~/vue_merge_request_widget/components/widget/widget.vue'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { s__, sprintf } from '~/locale'; +import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants'; +import securityReportMergeRequestDownloadPathsQuery from './graphql/security_report_merge_request_download_paths.query.graphql'; + +export default { + name: 'WidgetSecurityReportsCE', + components: { + MrWidget, + GlDropdown, + GlDropdownItem, + }, + directives: { + GlTooltip, + }, + i18n: { + apiError: s__( + 'SecurityReports|Failed to get security report information. Please reload the page or try again later.', + ), + scansHaveRun: s__('SecurityReports|Security scans have run'), + }, + props: { + mr: { + type: Object, + required: true, + }, + }, + data() { + return { + hasError: false, + }; + }, + reportTypes: ['sast', 'secret_detection'], + apollo: { + reportArtifacts: { + query: securityReportMergeRequestDownloadPathsQuery, + variables() { + return { + projectPath: this.mr.targetProjectFullPath, + iid: String(this.mr.iid), + reportTypes: this.$options.reportTypes.map((r) => r.toUpperCase()), + }; + }, + update(data) { + const artifacts = []; + + (data?.project?.mergeRequest?.headPipeline?.jobs?.nodes || []).forEach((reportType) => { + reportType.artifacts?.nodes.forEach((artifact) => { + if (artifact.fileType !== 'TRACE') { + artifacts.push({ + name: reportType.name, + id: reportType.id, + path: artifact.downloadPath, + }); + } + }); + }); + + return artifacts; + }, + error() { + this.hasError = true; + }, + }, + }, + computed: { + artifacts() { + return this.reportArtifacts || []; + }, + }, + methods: { + handleIsLoading(value) { + this.isLoading = value; + }, + + artifactText({ name }) { + return sprintf(s__('SecurityReports|Download %{artifactName}'), { + artifactName: name, + }); + }, + }, + widgetHelpPopover: { + options: { title: s__('ciReport|Security scan results') }, + content: { + text: s__( + 'ciReport|New vulnerabilities are vulnerabilities that the security scan detects in the merge request that are different to existing vulnerabilities in the default branch.', + ), + learnMorePath: helpPagePath('user/application_security/index', { + anchor: 'view-security-scan-information-in-merge-requests', + }), + }, + }, + icons: EXTENSION_ICONS, +}; +</script> + +<template> + <mr-widget + :has-error="hasError" + :error-text="$options.i18n.apiError" + :status-icon-name="$options.icons.warning" + :widget-name="$options.name" + :is-collapsible="false" + :help-popover="$options.widgetHelpPopover" + :summary="$options.i18n.scansHaveRun" + @is-loading="handleIsLoading" + > + <template v-if="artifacts.length > 0" #action-buttons> + <div class="gl-ml-3"> + <gl-dropdown + v-gl-tooltip + icon="download" + size="small" + category="tertiary" + variant="confirm" + right + > + <gl-dropdown-item + v-for="artifact in artifacts" + :key="artifact.path" + :href="artifact.path" + :data-testid="`download-${artifact.name}`" + download + > + {{ artifactText(artifact) }} + </gl-dropdown-item> + </gl-dropdown> + </div> + </template> + </mr-widget> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js index 5fd5950859b..c8d969e3adf 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js @@ -1,6 +1,6 @@ import Visibility from 'visibilityjs'; import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import Poll from '~/lib/utils/poll'; import * as types from './mutation_types'; @@ -63,7 +63,7 @@ export const fetchArtifacts = ({ state, dispatch }) => { export const receiveArtifactsSuccess = ({ commit }, response) => { // With 204 we keep polling and don't update the state - if (response.status === httpStatusCodes.OK) { + if (response.status === HTTP_STATUS_OK) { commit(types.RECEIVE_ARTIFACTS_SUCCESS, response.data); } }; diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue index c93057c491c..271cfd210a6 100644 --- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -66,6 +66,7 @@ export default { <template> <gl-link v-gl-tooltip + class="gl-display-inline-flex gl-align-items-center gl-line-height-0 gl-px-3 gl-py-2 gl-rounded-base" :class="cssClass" :title="title" data-qa-selector="status_badge_link" @@ -75,7 +76,7 @@ export default { <ci-icon :status="status" :css-classes="iconClasses" /> <template v-if="showText"> - {{ status.text }} + <span class="gl-ml-2">{{ status.text }}</span> </template> </gl-link> </template> diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue index 6a03e38a31d..47b96934420 100644 --- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue +++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue @@ -2,6 +2,7 @@ import { s__, sprintf } from '~/locale'; import SegmentedControlButtonGroup from '~/vue_shared/components/segmented_control_button_group.vue'; import CiCdAnalyticsAreaChart from './ci_cd_analytics_area_chart.vue'; +import { DEFAULT_SELECTED_CHART } from './constants'; export default { components: { @@ -20,7 +21,7 @@ export default { }, data() { return { - selectedChart: 0, + selectedChart: DEFAULT_SELECTED_CHART, }; }, computed: { diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/constants.js b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/constants.js index 1561674c0ad..3ac632b4690 100644 --- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/constants.js +++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/constants.js @@ -1 +1,2 @@ export const CHART_CONTAINER_HEIGHT = 300; +export const DEFAULT_SELECTED_CHART = 2; diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue index 8bffc2479a1..0d7547d88a1 100644 --- a/app/assets/javascripts/vue_shared/components/ci_icon.vue +++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue @@ -69,7 +69,7 @@ export default { computed: { wrapperStyleClasses() { const status = this.status.group; - return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status} gl-rounded-full gl-justify-content-center`; + return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status} gl-rounded-full gl-justify-content-center gl-line-height-0`; }, icon() { return this.isBorderless ? `${this.status.icon}_borderless` : this.status.icon; diff --git a/app/assets/javascripts/vue_shared/components/constants.js b/app/assets/javascripts/vue_shared/components/constants.js new file mode 100644 index 00000000000..b7ff715922d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/constants.js @@ -0,0 +1,4 @@ +export const KEY_EDIT = 'edit'; +export const KEY_WEB_IDE = 'webide'; +export const KEY_GITPOD = 'gitpod'; +export const KEY_PIPELINE_EDITOR = 'pipeline_editor'; 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 74905dc2ae0..9c30ec67d5a 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 @@ -67,6 +67,7 @@ export default { :suggestions="emojis" :suggestions-loading="loading" :get-active-token-value="getActiveEmoji" + value-identifier="name" v-bind="$attrs" @fetch-suggestions="fetchEmojis" v-on="$listeners" diff --git a/app/assets/javascripts/vue_shared/components/group_select/constants.js b/app/assets/javascripts/vue_shared/components/group_select/constants.js index bc70936eb36..06537d682fe 100644 --- a/app/assets/javascripts/vue_shared/components/group_select/constants.js +++ b/app/assets/javascripts/vue_shared/components/group_select/constants.js @@ -1,6 +1,7 @@ import { __ } from '~/locale'; export const TOGGLE_TEXT = __('Search for a group'); +export const RESET_LABEL = __('Reset'); export const FETCH_GROUPS_ERROR = __('Unable to fetch groups. Reload the page to try again.'); export const FETCH_GROUP_ERROR = __('Unable to fetch group. Reload the page to try again.'); export const QUERY_TOO_SHORT_MESSAGE = __('Enter at least three characters to search.'); diff --git a/app/assets/javascripts/vue_shared/components/group_select/group_select.vue b/app/assets/javascripts/vue_shared/components/group_select/group_select.vue index 5db723e1e5a..d295052e2ce 100644 --- a/app/assets/javascripts/vue_shared/components/group_select/group_select.vue +++ b/app/assets/javascripts/vue_shared/components/group_select/group_select.vue @@ -1,26 +1,35 @@ <script> import { debounce } from 'lodash'; -import { GlCollapsibleListbox } from '@gitlab/ui'; +import { GlFormGroup, GlAlert, GlCollapsibleListbox } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import axios from '~/lib/utils/axios_utils'; +import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; import Api from '~/api'; import { __ } from '~/locale'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { createAlert } from '~/flash'; import { groupsPath } from './utils'; import { TOGGLE_TEXT, + RESET_LABEL, FETCH_GROUPS_ERROR, FETCH_GROUP_ERROR, QUERY_TOO_SHORT_MESSAGE, } from './constants'; const MINIMUM_QUERY_LENGTH = 3; +const GROUPS_PER_PAGE = 20; export default { components: { + GlFormGroup, + GlAlert, GlCollapsibleListbox, }, props: { + label: { + type: String, + required: true, + }, inputName: { type: String, required: true, @@ -54,10 +63,14 @@ export default { return { pristine: true, searching: false, + hasMoreGroups: true, + infiniteScrollLoading: false, searchString: '', groups: [], + page: 1, selectedValue: null, selectedText: null, + errorMessage: '', }; }, computed: { @@ -74,6 +87,9 @@ export default { toggleText() { return this.selectedText ?? this.$options.i18n.toggleText; }, + resetButtonLabel() { + return this.clearable ? RESET_LABEL : ''; + }, inputValue() { return this.selectedValue ? this.selectedValue : ''; }, @@ -95,35 +111,48 @@ export default { if (this.isSearchQueryTooShort) { this.groups = []; } else { - this.fetchGroups(searchString); + this.fetchGroups(); } }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), - async fetchGroups(searchString = '') { - this.searching = true; + async fetchGroups(page = 1) { + if (page === 1) { + this.searching = true; + this.groups = []; + this.hasMoreGroups = true; + } else { + this.infiniteScrollLoading = true; + } try { - const { data } = await axios.get( + const { data, headers } = await axios.get( Api.buildUrl(groupsPath(this.groupsFilter, this.parentGroupID)), { params: { - search: searchString, + search: this.searchString, + per_page: GROUPS_PER_PAGE, + page, }, }, ); const groups = data.length ? data : data.results || []; - this.groups = groups.map((group) => ({ - ...group, - value: String(group.id), - })); + this.groups.push( + ...groups.map((group) => ({ + ...group, + value: String(group.id), + })), + ); + + const { totalPages } = parseIntPagination(normalizeHeaders(headers)); + if (page === totalPages) { + this.hasMoreGroups = false; + } + this.page = page; this.searching = false; + this.infiniteScrollLoading = false; } catch (error) { - createAlert({ - message: FETCH_GROUPS_ERROR, - error, - parent: this.$el, - }); + this.handleError({ message: FETCH_GROUPS_ERROR, error }); } }, async fetchInitialSelection() { @@ -139,11 +168,7 @@ export default { this.pristine = false; this.searching = false; } catch (error) { - createAlert({ - message: FETCH_GROUP_ERROR, - error, - parent: this.$el, - }); + this.handleError({ message: FETCH_GROUP_ERROR, error }); } }, onShown() { @@ -154,11 +179,20 @@ export default { onReset() { this.selected = null; }, + onBottomReached() { + this.fetchGroups(this.page + 1); + }, + handleError({ message, error }) { + Sentry.captureException(error); + this.errorMessage = message; + }, + dismissError() { + this.errorMessage = ''; + }, }, i18n: { toggleText: TOGGLE_TEXT, selectGroup: __('Select a group'), - reset: __('Reset'), noResultsText: __('No results found.'), searchQueryTooShort: QUERY_TOO_SHORT_MESSAGE, }, @@ -166,21 +200,27 @@ export default { </script> <template> - <div> + <gl-form-group :label="label"> + <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" @dismiss="dismissError">{{ + errorMessage + }}</gl-alert> <gl-collapsible-listbox ref="listbox" v-model="selected" :header-text="$options.i18n.selectGroup" - :reset-button-label="$options.i18n.reset" + :reset-button-label="resetButtonLabel" :toggle-text="toggleText" :loading="searching && pristine" :searching="searching" :items="groups" :no-results-text="noResultsText" + :infinite-scroll="hasMoreGroups" + :infinite-scroll-loading="infiniteScrollLoading" searchable @shown="onShown" @search="search" @reset="onReset" + @bottom-reached="onBottomReached" > <template #list-item="{ item }"> <div class="gl-font-weight-bold"> @@ -189,7 +229,6 @@ export default { <div class="gl-text-gray-300">{{ item.full_path }}</div> </template> </gl-collapsible-listbox> - <div class="flash-container"></div> <input :id="inputId" data-testid="input" type="hidden" :name="inputName" :value="inputValue" /> - </div> + </gl-form-group> </template> diff --git a/app/assets/javascripts/vue_shared/components/group_select/init_group_selects.js b/app/assets/javascripts/vue_shared/components/group_select/init_group_selects.js new file mode 100644 index 00000000000..dbfac8a0339 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/group_select/init_group_selects.js @@ -0,0 +1,48 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import GroupSelect from './group_select.vue'; + +const SELECTOR = '.js-vue-group-select'; + +export const initGroupSelects = () => { + if (process.env.NODE_ENV !== 'production' && document.querySelector(SELECTOR) === null) { + // eslint-disable-next-line no-console + console.warn(`Attempted to initialize GroupSelect but '${SELECTOR}' not found in the page`); + } + + [...document.querySelectorAll(SELECTOR)].forEach((el) => { + const { + parentId: parentGroupID, + groupsFilter, + label, + inputName, + inputId, + selected: initialSelection, + testid, + } = el.dataset; + const clearable = parseBoolean(el.dataset.clearable); + + return new Vue({ + el, + components: { + GroupSelect, + }, + render(createElement) { + return createElement(GroupSelect, { + props: { + label, + inputName, + initialSelection, + parentGroupID, + groupsFilter, + inputId, + clearable, + }, + attrs: { + 'data-testid': testid, + }, + }); + }, + }); + }); +}; diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index 3c4ae08d2f7..8e459cc21ac 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -75,6 +75,10 @@ export default { // GraphQL returns `avatarUrl` and Rest `avatar_url` return this.user?.avatarUrl || this.user?.avatar_url; }, + webUrl() { + // GraphQL returns `webUrl` and Rest `web_url` + return this.user?.webUrl || this.user?.web_url; + }, statusTooltipHTML() { // Rest `status_tooltip_html` which is a ready to work // html for the emoji and the status text inside a tooltip. @@ -132,7 +136,7 @@ export default { :data-user-id="userId" :data-username="user.username" :data-name="user.name" - :href="user.webUrl" + :href="webUrl" target="_blank" class="js-user-link gl-vertical-align-middle gl-mx-2 gl-align-items-center" > diff --git a/app/assets/javascripts/vue_shared/components/listbox_input/init_listbox_inputs.js b/app/assets/javascripts/vue_shared/components/listbox_input/init_listbox_inputs.js new file mode 100644 index 00000000000..ad89b78b521 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/listbox_input/init_listbox_inputs.js @@ -0,0 +1,42 @@ +import Vue from 'vue'; +import ListboxInput from '~/vue_shared/components/listbox_input/listbox_input.vue'; + +export const initListboxInputs = () => { + const els = [...document.querySelectorAll('.js-listbox-input')]; + + els.forEach((el, index) => { + const { label, description, name, defaultToggleText, value = null } = el.dataset; + const { id } = el; + const items = JSON.parse(el.dataset.items); + + return new Vue({ + el, + name: `ListboxInputRoot${index + 1}`, + data() { + return { + selected: value, + }; + }, + render(createElement) { + return createElement(ListboxInput, { + on: { + select: (newValue) => { + this.selected = newValue; + }, + }, + props: { + label, + description, + name, + defaultToggleText, + selected: this.selected, + items, + }, + attrs: { + id, + }, + }); + }, + }); + }); +}; diff --git a/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue b/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue index b1809e6a9f3..bc6b5d3176f 100644 --- a/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue +++ b/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue @@ -1,25 +1,37 @@ <script> -import { GlListbox } from '@gitlab/ui'; +import { GlFormGroup, GlListbox } from '@gitlab/ui'; import { __ } from '~/locale'; -const MIN_ITEMS_COUNT_FOR_SEARCHING = 20; +const MIN_ITEMS_COUNT_FOR_SEARCHING = 10; export default { i18n: { noResultsText: __('No results found'), }, components: { + GlFormGroup, GlListbox, }, model: GlListbox.model, props: { + label: { + type: String, + required: false, + default: '', + }, + description: { + type: String, + required: false, + default: '', + }, name: { type: String, required: true, }, defaultToggleText: { type: String, - required: true, + required: false, + default: '', }, selected: { type: String, @@ -30,6 +42,11 @@ export default { type: GlListbox.props.items.type, required: true, }, + disabled: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -37,6 +54,9 @@ export default { }; }, computed: { + wrapperComponent() { + return this.label || this.description ? 'gl-form-group' : 'div'; + }, allOptions() { const allOptions = []; @@ -95,16 +115,17 @@ export default { </script> <template> - <div> + <component :is="wrapperComponent" :label="label" :description="description" v-bind="$attrs"> <gl-listbox :selected="selected" :toggle-text="toggleText" :items="filteredItems" :searchable="isSearchable" :no-results-text="$options.i18n.noResultsText" + :disabled="disabled" @search="search" @select="$emit($options.model.event, $event)" /> <input ref="input" type="hidden" :name="name" :value="selected" /> - </div> + </component> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue new file mode 100644 index 00000000000..6702a81e747 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue @@ -0,0 +1,58 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlDropdown, + GlDropdownItem, + }, + props: { + size: { + type: String, + required: false, + default: 'medium', + }, + value: { + type: String, + required: true, + }, + }, + computed: { + markdownEditorSelected() { + return this.value === 'markdown'; + }, + text() { + return this.markdownEditorSelected ? __('View rich text') : __('View markdown'); + }, + }, +}; +</script> +<template> + <gl-dropdown + category="tertiary" + data-qa-selector="editing_mode_switcher" + :size="size" + :text="text" + right + > + <gl-dropdown-item + is-check-item + :is-checked="!markdownEditorSelected" + @click="$emit('input', 'richText')" + ><div class="gl-font-weight-bold">{{ __('Rich text') }}</div> + <div class="gl-text-secondary"> + {{ __('View the formatted output in real-time as you edit.') }} + </div> + </gl-dropdown-item> + <gl-dropdown-item + is-check-item + :is-checked="markdownEditorSelected" + @click="$emit('input', 'markdown')" + ><div class="gl-font-weight-bold">{{ __('Markdown') }}</div> + <div class="gl-text-secondary"> + {{ __('View and edit markdown, with the option to preview the formatted output.') }} + </div></gl-dropdown-item + > + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index b5f2602af5e..7b76fc3fc6d 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -122,6 +122,11 @@ export default { required: false, default: () => [], }, + showContentEditorSwitcher: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -364,6 +369,8 @@ export default { :quick-actions-docs-path="quickActionsDocsPath" :can-attach-file="canAttachFile" :show-comment-tool-bar="showCommentToolBar" + :show-content-editor-switcher="showContentEditorSwitcher" + @enableContentEditor="$emit('enableContentEditor')" /> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 89fffdedbfd..e83441e59a2 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -10,6 +10,7 @@ import { INDENT_LINE, OUTDENT_LINE, } from '~/behaviors/shortcuts/keybindings'; +import { getModifierKey } from '~/constants'; import { getSelectedFragment } from '~/lib/utils/common_utils'; import { s__, __ } from '~/locale'; import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm'; @@ -66,6 +67,7 @@ export default { return { tag: '> ', suggestPopoverVisible: false, + modifierKey: getModifierKey(), }; }, computed: { @@ -90,15 +92,6 @@ export default { const expandText = s__('MarkdownEditor|Click to expand'); return [`<details><summary>${expandText}</summary>`, `{text}`, '</details>'].join('\n'); }, - isMac() { - // Accessing properties using ?. to allow tests to use - // this component without setting up window.gl.client. - // In production, window.gl.client should always be present. - return Boolean(window.gl?.client?.isMac); - }, - modifierKey() { - return this.isMac ? '⌘' : s__('KeyboardKey|Ctrl+'); - }, }, watch: { showSuggestPopover() { diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue index d01eae0308f..c53118b9f62 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -1,16 +1,13 @@ <script> -import { GlSegmentedControl } from '@gitlab/ui'; -import { __ } from '~/locale'; -import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import axios from '~/lib/utils/axios_utils'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { EDITING_MODE_MARKDOWN_FIELD, EDITING_MODE_CONTENT_EDITOR } from '../../constants'; import MarkdownField from './field.vue'; export default { components: { - MarkdownField, LocalStorageSync, - GlSegmentedControl, + MarkdownField, ContentEditor: () => import( /* webpackChunkName: 'content_editor' */ '~/content_editor/components/content_editor.vue' @@ -91,7 +88,6 @@ export default { data() { return { editingMode: EDITING_MODE_MARKDOWN_FIELD, - switchEditingControlEnabled: true, autofocused: false, }; }, @@ -114,19 +110,16 @@ export default { updateMarkdownFromMarkdownField({ target }) { this.$emit('input', target.value); }, - enableSwitchEditingControl() { - this.switchEditingControlEnabled = true; - }, - disableSwitchEditingControl() { - this.switchEditingControlEnabled = false; - }, renderMarkdown(markdown) { return axios.post(this.renderMarkdownPath, { text: markdown }).then(({ data }) => data.body); }, onEditingModeChange(editingMode) { + this.editingMode = editingMode; this.notifyEditingModeChange(editingMode); }, onEditingModeRestored(editingMode) { + this.editingMode = editingMode; + this.$emit(editingMode); this.notifyEditingModeChange(editingMode); }, notifyEditingModeChange(editingMode) { @@ -142,25 +135,10 @@ export default { this.autofocused = true; }, }, - switchEditingControlOptions: [ - { text: __('Source'), value: EDITING_MODE_MARKDOWN_FIELD }, - { text: __('Rich text'), value: EDITING_MODE_CONTENT_EDITOR }, - ], }; </script> <template> <div> - <div class="gl-display-flex gl-justify-content-start gl-mb-3"> - <gl-segmented-control - v-model="editingMode" - data-testid="toggle-editing-mode-button" - data-qa-selector="editing_mode_button" - class="gl-display-flex" - :options="$options.switchEditingControlOptions" - :disabled="!enableContentEditor || !switchEditingControlEnabled" - @change="onEditingModeChange" - /> - </div> <local-storage-sync v-model="editingMode" storage-key="gl-wiki-content-editor-enabled" @@ -176,7 +154,9 @@ export default { :quick-actions-docs-path="quickActionsDocsPath" :uploads-path="uploadsPath" :enable-preview="enablePreview" + show-content-editor-switcher class="bordered-box" + @enableContentEditor="onEditingModeChange('contentEditor')" > <template #textarea> <textarea @@ -205,10 +185,8 @@ export default { :use-bottom-toolbar="useBottomToolbar" @initialized="setEditorAsAutofocused" @change="updateMarkdownFromContentEditor" - @loading="disableSwitchEditingControl" - @loadingSuccess="enableSwitchEditingControl" - @loadingError="enableSwitchEditingControl" @keydown="$emit('keydown', $event)" + @enableMarkdownEditor="onEditingModeChange('markdownField')" /> <input :id="formFieldId" diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index b5640e12541..e8be242f660 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon } from '@gitlab/ui'; +import EditorModeDropdown from './editor_mode_dropdown.vue'; export default { components: { @@ -8,6 +9,7 @@ export default { GlLoadingIcon, GlSprintf, GlIcon, + EditorModeDropdown, }, props: { markdownDocsPath: { @@ -29,12 +31,24 @@ export default { required: false, default: true, }, + showContentEditorSwitcher: { + type: Boolean, + required: false, + default: false, + }, }, computed: { hasQuickActionsDocsPath() { return this.quickActionsDocsPath !== ''; }, }, + methods: { + handleEditorModeChanged(mode) { + if (mode === 'richText') { + this.$emit('enableContentEditor'); + } + }, + }, }; </script> @@ -121,5 +135,12 @@ export default { {{ __('Cancel') }} </gl-button> </span> + <editor-mode-dropdown + v-if="showContentEditorSwitcher" + size="small" + class="gl-float-right gl-line-height-28 gl-display-block" + value="markdown" + @input="handleEditorModeChanged" + /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue index 21212e82de4..c83643ca4de 100644 --- a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue +++ b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue @@ -1,6 +1,6 @@ <script> import { GlBadge, GlTabs, GlTab } from '@gitlab/ui'; -import $ from 'jquery'; +import { initScrollingTabs } from '~/layout_nav'; /** * Given an array of tabs, renders non linked bootstrap tabs. @@ -41,7 +41,7 @@ export default { }, }, mounted() { - $(document).trigger('init.scrolling-tabs'); + initScrollingTabs(); }, methods: { shouldRenderBadge(count) { diff --git a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue index 5f2a66ee0b7..e1f042f78ab 100644 --- a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue +++ b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue @@ -64,8 +64,9 @@ export default { <template> <gl-pagination v-if="showPagination" - class="justify-content-center gl-mt-3" + class="gl-mt-3" v-bind="$attrs" + align="center" :value="pageInfo.page" :per-page="pageInfo.perPage" :total-items="pageInfo.total" diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js deleted file mode 100644 index 88c975b97b9..00000000000 --- a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js +++ /dev/null @@ -1,63 +0,0 @@ -import { s__, sprintf } from '~/locale'; - -export const README_URL = - 'https://gitlab.com/guided-explorations/aws/gitlab-runner-autoscaling-aws-asg/-/blob/main/easybuttons.md'; - -export const CF_BASE_URL = - 'https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/create/review?'; - -export const TEMPLATES_BASE_URL = 'https://gl-public-templates.s3.amazonaws.com/cfn/experimental/'; - -export const EASY_BUTTONS = [ - { - stackName: 'linux-docker-nonspot', - templateName: - 'easybutton-amazon-linux-2-docker-manual-scaling-with-schedule-ondemandonly.cf.yml', - description: s__( - 'Runners|Amazon Linux 2 Docker HA with manual scaling and optional scheduling. Non-spot.', - ), - moreDetails1: s__('Runners|No spot. This is the default choice for Linux Docker executor.'), - moreDetails2: s__( - 'Runners|A capacity of 1 enables warm HA through Auto Scaling group re-spawn. A capacity of 2 enables hot HA because the service is available even when a node is lost. A capacity of 3 or more enables hot HA and manual scaling of runner fleet.', - ), - }, - { - stackName: 'linux-docker-spotonly', - templateName: 'easybutton-amazon-linux-2-docker-manual-scaling-with-schedule-spotonly.cf.yml', - description: sprintf( - s__( - 'Runners|Amazon Linux 2 Docker HA with manual scaling and optional scheduling. %{percentage} spot.', - ), - { percentage: '100%' }, - ), - moreDetails1: sprintf(s__('Runners|%{percentage} spot.'), { percentage: '100%' }), - moreDetails2: s__( - 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.', - ), - }, - { - stackName: 'win2019-shell-non-spot', - templateName: 'easybutton-windows2019-shell-manual-scaling-with-scheduling-ondemandonly.cf.yml', - description: s__( - 'Runners|Windows 2019 Shell with manual scaling and optional scheduling. Non-spot.', - ), - moreDetails1: s__('Runners|No spot. Default choice for Windows Shell executor.'), - moreDetails2: s__( - 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.', - ), - }, - { - stackName: 'win2019-shell-spot', - templateName: 'easybutton-windows2019-shell-manual-scaling-with-scheduling-spotonly.cf.yml', - description: sprintf( - s__( - 'Runners|Windows 2019 Shell with manual scaling and optional scheduling. %{percentage} spot.', - ), - { percentage: '100%' }, - ), - moreDetails1: sprintf(s__('Runners|%{percentage} spot.'), { percentage: '100%' }), - moreDetails2: s__( - 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.', - ), - }, -]; diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue index eee65d90285..08acde1aefc 100644 --- a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue +++ b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue @@ -1,125 +1,29 @@ <script> -import { - GlModal, - GlSprintf, - GlLink, - GlFormRadioGroup, - GlFormRadio, - GlAccordion, - GlAccordionItem, -} from '@gitlab/ui'; -import Tracking from '~/tracking'; -import { getBaseURL, objectToQuery, visitUrl } from '~/lib/utils/url_utility'; -import { __, s__ } from '~/locale'; -import { README_URL, CF_BASE_URL, TEMPLATES_BASE_URL, EASY_BUTTONS } from './constants'; +import { GlModal } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import RunnerAwsInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue'; export default { components: { GlModal, - GlSprintf, - GlLink, - GlFormRadioGroup, - GlFormRadio, - GlAccordion, - GlAccordionItem, + RunnerAwsInstructions, }, - mixins: [Tracking.mixin()], props: { modalId: { type: String, required: true, }, }, - data() { - return { - selected: this.$options.easyButtons[0], - }; - }, methods: { - borderBottom(idx) { - return idx < this.$options.easyButtons.length - 1; - }, - easyButtonUrl(easyButton) { - const params = { - templateURL: TEMPLATES_BASE_URL + easyButton.templateName, - stackName: easyButton.stackName, - param_3GITLABRunnerInstanceURL: getBaseURL(), - }; - return CF_BASE_URL + objectToQuery(params); - }, - trackCiRunnerTemplatesClick(stackName) { - this.track('template_clicked', { - label: stackName, - }); - }, - handleModalPrimary() { - this.trackCiRunnerTemplatesClick(this.selected.stackName); - visitUrl(this.easyButtonUrl(this.selected), true); + onClose() { + this.$refs.modal.close(); }, }, - i18n: { - title: s__('Runners|Deploy GitLab Runner in AWS'), - instructions: s__( - 'Runners|Select your preferred option here. In the next step, you can choose the capacity for your runner in the AWS CloudFormation console.', - ), - chooseRunner: s__('Runners|Choose your preferred GitLab Runner'), - dontSeeWhatYouAreLookingFor: s__( - "Runners|Don't see what you are looking for? See the full list of options, including a fully customizable option %{linkStart}here%{linkEnd}.", - ), - moreDetails: __('More Details'), - lessDetails: __('Less Details'), - }, - deployButton: { - text: s__('Runners|Deploy GitLab Runner in AWS'), - attributes: [{ variant: 'confirm' }], - }, - closeButton: { - text: __('Cancel'), - attributes: [{ variant: 'default' }], - }, - readmeUrl: README_URL, - easyButtons: EASY_BUTTONS, + i18n_title: s__('Runners|Deploy GitLab Runner in AWS'), }; </script> <template> - <gl-modal - :modal-id="modalId" - :title="$options.i18n.title" - :action-primary="$options.deployButton" - :action-secondary="$options.closeButton" - size="sm" - @primary="handleModalPrimary" - > - <p>{{ $options.i18n.instructions }}</p> - <gl-form-radio-group v-model="selected" :label="$options.i18n.chooseRunner" label-sr-only> - <gl-form-radio - v-for="(easyButton, idx) in $options.easyButtons" - :key="easyButton.templateName" - :value="easyButton" - class="gl-py-5 gl-pl-8" - :class="{ 'gl-border-b': borderBottom(idx) }" - > - <div class="gl-mt-n1 gl-pl-4 gl-pb-2 gl-font-weight-bold"> - {{ easyButton.description }} - <gl-accordion :header-level="3" class="gl-pt-3"> - <gl-accordion-item - :title="$options.i18n.moreDetails" - :title-visible="$options.i18n.lessDetails" - class="gl-font-weight-normal" - > - <p class="gl-pt-2">{{ easyButton.moreDetails1 }}</p> - <p class="gl-m-0">{{ easyButton.moreDetails2 }}</p> - </gl-accordion-item> - </gl-accordion> - </div> - </gl-form-radio> - </gl-form-radio-group> - <p> - <gl-sprintf :message="$options.i18n.dontSeeWhatYouAreLookingFor"> - <template #link="{ content }"> - <gl-link :href="$options.readmeUrl" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </p> + <gl-modal ref="modal" :modal-id="modalId" :title="$options.i18n_title" hide-footer size="sm"> + <runner-aws-instructions @close="onClose" /> </gl-modal> </template> diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js index c97e191b630..3dbc5246c3d 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js @@ -1,18 +1,69 @@ -import { s__ } from '~/locale'; +import { s__, sprintf } from '~/locale'; export const REGISTRATION_TOKEN_PLACEHOLDER = '$REGISTRATION_TOKEN'; -export const INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES = { - docker: { - instructions: s__( - 'Runners|To install Runner in a container follow the instructions described in the GitLab documentation', +export const PLATFORM_DOCKER = 'docker'; +export const PLATFORM_KUBERNETES = 'kubernetes'; + +export const AWS_README_URL = + 'https://gitlab.com/guided-explorations/aws/gitlab-runner-autoscaling-aws-asg/-/blob/main/easybuttons.md'; + +export const AWS_CF_BASE_URL = + 'https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/create/review?'; + +export const AWS_TEMPLATES_BASE_URL = + 'https://gl-public-templates.s3.amazonaws.com/cfn/experimental/'; + +export const AWS_EASY_BUTTONS = [ + { + stackName: 'linux-docker-nonspot', + templateName: + 'easybutton-amazon-linux-2-docker-manual-scaling-with-schedule-ondemandonly.cf.yml', + description: s__( + 'Runners|Amazon Linux 2 Docker HA with manual scaling and optional scheduling. Non-spot.', + ), + moreDetails1: s__('Runners|No spot. This is the default choice for Linux Docker executor.'), + moreDetails2: s__( + 'Runners|A capacity of 1 enables warm HA through Auto Scaling group re-spawn. A capacity of 2 enables hot HA because the service is available even when a node is lost. A capacity of 3 or more enables hot HA and manual scaling of runner fleet.', + ), + }, + { + stackName: 'linux-docker-spotonly', + templateName: 'easybutton-amazon-linux-2-docker-manual-scaling-with-schedule-spotonly.cf.yml', + description: sprintf( + s__( + 'Runners|Amazon Linux 2 Docker HA with manual scaling and optional scheduling. %{percentage} spot.', + ), + { percentage: '100%' }, + ), + moreDetails1: sprintf(s__('Runners|%{percentage} spot.'), { percentage: '100%' }), + moreDetails2: s__( + 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.', + ), + }, + { + stackName: 'win2019-shell-non-spot', + templateName: 'easybutton-windows2019-shell-manual-scaling-with-scheduling-ondemandonly.cf.yml', + description: s__( + 'Runners|Windows 2019 Shell with manual scaling and optional scheduling. Non-spot.', + ), + moreDetails1: s__('Runners|No spot. Default choice for Windows Shell executor.'), + moreDetails2: s__( + 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.', ), - link: 'https://docs.gitlab.com/runner/install/docker.html', }, - kubernetes: { - instructions: s__( - 'Runners|To install Runner in Kubernetes follow the instructions described in the GitLab documentation.', + { + stackName: 'win2019-shell-spot', + templateName: 'easybutton-windows2019-shell-manual-scaling-with-scheduling-spotonly.cf.yml', + description: sprintf( + s__( + 'Runners|Windows 2019 Shell with manual scaling and optional scheduling. %{percentage} spot.', + ), + { percentage: '100%' }, + ), + moreDetails1: sprintf(s__('Runners|%{percentage} spot.'), { percentage: '100%' }), + moreDetails2: s__( + 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.', ), - link: 'https://docs.gitlab.com/runner/install/kubernetes.html', }, -}; +]; diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/get_runner_platforms.query.graphql index 76f152e5453..76f152e5453 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/get_runner_platforms.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/get_runner_setup.query.graphql index c0248a35e3f..c0248a35e3f 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/get_runner_setup.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue new file mode 100644 index 00000000000..cafebdfe5f4 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue @@ -0,0 +1,123 @@ +<script> +import { + GlButton, + GlSprintf, + GlLink, + GlFormRadioGroup, + GlFormRadio, + GlAccordion, + GlAccordionItem, +} from '@gitlab/ui'; +import Tracking from '~/tracking'; +import { getBaseURL, objectToQuery, visitUrl } from '~/lib/utils/url_utility'; +import { __, s__ } from '~/locale'; +import { + AWS_README_URL, + AWS_CF_BASE_URL, + AWS_TEMPLATES_BASE_URL, + AWS_EASY_BUTTONS, +} from '../constants'; + +export default { + components: { + GlButton, + GlSprintf, + GlLink, + GlFormRadioGroup, + GlFormRadio, + GlAccordion, + GlAccordionItem, + }, + mixins: [Tracking.mixin()], + data() { + return { + selectedIndex: 0, + }; + }, + computed: { + selected() { + return this.$options.easyButtons[this.selectedIndex]; + }, + }, + methods: { + borderBottom(idx) { + return idx < this.$options.easyButtons.length - 1; + }, + easyButtonUrl(easyButton) { + const params = { + templateURL: AWS_TEMPLATES_BASE_URL + easyButton.templateName, + stackName: easyButton.stackName, + param_3GITLABRunnerInstanceURL: getBaseURL(), + }; + return AWS_CF_BASE_URL + objectToQuery(params); + }, + trackCiRunnerTemplatesClick(stackName) { + this.track('template_clicked', { + label: stackName, + }); + }, + onOk() { + this.trackCiRunnerTemplatesClick(this.selected.stackName); + visitUrl(this.easyButtonUrl(this.selected), true); + }, + onClose() { + this.$emit('close'); + }, + }, + i18n: { + title: s__('Runners|Deploy GitLab Runner in AWS'), + instructions: s__( + 'Runners|Select your preferred option here. In the next step, you can choose the capacity for your runner in the AWS CloudFormation console.', + ), + chooseRunner: s__('Runners|Choose your preferred GitLab Runner'), + dontSeeWhatYouAreLookingFor: s__( + "Runners|Don't see what you are looking for? See the full list of options, including a fully customizable option %{linkStart}here%{linkEnd}.", + ), + moreDetails: __('More Details'), + lessDetails: __('Less Details'), + }, + readmeUrl: AWS_README_URL, + easyButtons: AWS_EASY_BUTTONS, +}; +</script> +<template> + <div> + <p>{{ $options.i18n.instructions }}</p> + <gl-form-radio-group v-model="selectedIndex" :label="$options.i18n.chooseRunner" label-sr-only> + <gl-form-radio + v-for="(easyButton, idx) in $options.easyButtons" + :key="easyButton.templateName" + :value="idx" + class="gl-py-5 gl-pl-8" + :class="{ 'gl-border-b': borderBottom(idx) }" + > + <div class="gl-mt-n1 gl-pl-4 gl-pb-2 gl-font-weight-bold"> + {{ easyButton.description }} + <gl-accordion :header-level="3" class="gl-pt-3"> + <gl-accordion-item + :title="$options.i18n.moreDetails" + :title-visible="$options.i18n.lessDetails" + class="gl-font-weight-normal" + > + <p class="gl-pt-2">{{ easyButton.moreDetails1 }}</p> + <p class="gl-m-0">{{ easyButton.moreDetails2 }}</p> + </gl-accordion-item> + </gl-accordion> + </div> + </gl-form-radio> + </gl-form-radio-group> + <p> + <gl-sprintf :message="$options.i18n.dontSeeWhatYouAreLookingFor"> + <template #link="{ content }"> + <gl-link :href="$options.readmeUrl" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + <footer class="gl-display-flex gl-justify-content-end gl-pt-3 gl-gap-3"> + <gl-button @click="onClose()">{{ __('Close') }}</gl-button> + <gl-button variant="confirm" @click="onOk()"> + {{ s__('Runners|Deploy GitLab Runner in AWS') }} + </gl-button> + </footer> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_cli_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_cli_instructions.vue new file mode 100644 index 00000000000..36e608a068b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_cli_instructions.vue @@ -0,0 +1,169 @@ +<script> +import { GlButton, GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; +import { REGISTRATION_TOKEN_PLACEHOLDER } from '../constants'; +import getRunnerSetupInstructionsQuery from '../graphql/get_runner_setup.query.graphql'; + +export default { + components: { + GlButton, + GlDropdown, + GlDropdownItem, + GlLoadingIcon, + ModalCopyButton, + }, + props: { + platform: { + type: Object, + required: false, + default: null, + }, + registrationToken: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + selectedArchitecture: this.platform?.architectures[0] || null, + instructions: null, + }; + }, + apollo: { + instructions: { + query: getRunnerSetupInstructionsQuery, + skip() { + return !this.platform || !this.selectedArchitecture; + }, + variables() { + return { + platform: this.platform.name, + architecture: this.selectedArchitecture.name, + }; + }, + update(data) { + return data?.runnerSetup; + }, + error() { + this.$emit('error'); + }, + }, + }, + computed: { + architectures() { + return this.platform?.architectures || []; + }, + binaryUrl() { + return this.selectedArchitecture?.downloadLocation; + }, + registerInstructionsWithToken() { + const { registerInstructions } = this.instructions || {}; + + if (this.registrationToken) { + return registerInstructions?.replace( + REGISTRATION_TOKEN_PLACEHOLDER, + this.registrationToken, + ); + } + return registerInstructions; + }, + }, + watch: { + platform() { + // reset selection if architecture is not in this list + const arch = this.architectures.find(({ name }) => name === this.selectedArchitecture.name); + if (!arch) { + this.selectArchitecture(this.architectures[0]); + } + }, + }, + methods: { + selectArchitecture(architecture) { + this.selectedArchitecture = architecture; + }, + onClose() { + this.$emit('close'); + }, + }, + i18n: { + architecture: s__('Runners|Architecture'), + downloadInstallBinary: s__('Runners|Download and install binary'), + downloadLatestBinary: s__('Runners|Download latest binary'), + registerRunnerCommand: s__('Runners|Command to register runner'), + copyInstructions: s__('Runners|Copy instructions'), + }, +}; +</script> + +<template> + <div> + <h5> + {{ $options.i18n.architecture }} + <gl-loading-icon v-if="$apollo.loading" size="sm" inline /> + </h5> + + <gl-dropdown class="gl-mb-3" :text="selectedArchitecture.name"> + <gl-dropdown-item + v-for="architecture in architectures" + :key="architecture.name" + is-check-item + :is-checked="selectedArchitecture.name === architecture.name" + data-testid="architecture-dropdown-item" + @click="selectArchitecture(architecture)" + > + {{ architecture.name }} + </gl-dropdown-item> + </gl-dropdown> + <div class="gl-sm-display-flex gl-align-items-center gl-mb-3"> + <h5>{{ $options.i18n.downloadInstallBinary }}</h5> + <gl-button + v-if="binaryUrl" + class="gl-ml-auto" + :href="binaryUrl" + download + icon="download" + data-testid="binary-download-button" + > + {{ $options.i18n.downloadLatestBinary }} + </gl-button> + </div> + + <template v-if="instructions"> + <div class="gl-display-flex"> + <pre + class="gl-bg-gray gl-flex-grow-1 gl-white-space-pre-line" + data-testid="binary-instructions" + >{{ instructions.installInstructions }}</pre + > + <modal-copy-button + :title="$options.i18n.copyInstructions" + :text="instructions.installInstructions" + :modal-id="$options.modalId" + css-classes="gl-align-self-start gl-ml-2 gl-mt-2" + category="tertiary" + /> + </div> + <h5 class="gl-mb-3">{{ $options.i18n.registerRunnerCommand }}</h5> + <div class="gl-display-flex"> + <pre + class="gl-bg-gray gl-flex-grow-1 gl-white-space-pre-line" + data-testid="register-command" + >{{ registerInstructionsWithToken }}</pre + > + <modal-copy-button + :title="$options.i18n.copyInstructions" + :text="registerInstructionsWithToken" + :modal-id="$options.modalId" + css-classes="gl-align-self-start gl-ml-2 gl-mt-2" + category="tertiary" + /> + </div> + </template> + + <footer class="gl-display-flex gl-justify-content-end gl-pt-3"> + <gl-button @click="onClose()">{{ __('Close') }}</gl-button> + </footer> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_docker_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_docker_instructions.vue new file mode 100644 index 00000000000..ff7e803af2a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_docker_instructions.vue @@ -0,0 +1,35 @@ +<script> +import { GlButton, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlButton, + GlIcon, + }, + methods: { + onClose() { + this.$emit('close'); + }, + }, + I18N_INSTRUCTIONS_TEXT: s__( + 'Runners|To install Runner in a container follow the instructions described in the GitLab documentation', + ), + I18N_VIEW_INSTRUCTIONS: s__('Runners|View installation instructions'), + HELP_URL: 'https://docs.gitlab.com/runner/install/docker.html', +}; +</script> +<template> + <div> + <p> + {{ $options.I18N_INSTRUCTIONS_TEXT }} + </p> + <gl-button :href="$options.HELP_URL"> + <gl-icon name="external-link" /> + {{ $options.I18N_VIEW_INSTRUCTIONS }} + </gl-button> + <footer class="gl-display-flex gl-justify-content-end gl-pt-3"> + <gl-button @click="onClose()">{{ __('Close') }}</gl-button> + </footer> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions.vue new file mode 100644 index 00000000000..ee41dab0cec --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_kubernetes_instructions.vue @@ -0,0 +1,35 @@ +<script> +import { GlButton, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlButton, + GlIcon, + }, + methods: { + onClose() { + this.$emit('close'); + }, + }, + I18N_INSTRUCTIONS_TEXT: s__( + 'Runners|To install Runner in Kubernetes follow the instructions described in the GitLab documentation.', + ), + I18N_VIEW_INSTRUCTIONS: s__('Runners|View installation instructions'), + HELP_URL: 'https://docs.gitlab.com/runner/install/kubernetes.html', +}; +</script> +<template> + <div> + <p> + {{ $options.I18N_INSTRUCTIONS_TEXT }} + </p> + <gl-button :href="$options.HELP_URL"> + <gl-icon name="external-link" /> + {{ $options.I18N_VIEW_INSTRUCTIONS }} + </gl-button> + <footer class="gl-display-flex gl-justify-content-end gl-pt-3"> + <gl-button @click="onClose()">{{ __('Close') }}</gl-button> + </footer> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue index c5d3704ead9..729fe9c462c 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue @@ -12,15 +12,13 @@ import { GlResizeObserverDirective, } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import { isEmpty } from 'lodash'; import { __, s__ } from '~/locale'; -import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; -import { - INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES, - REGISTRATION_TOKEN_PLACEHOLDER, -} from './constants'; -import getRunnerPlatformsQuery from './graphql/queries/get_runner_platforms.query.graphql'; -import getRunnerSetupInstructionsQuery from './graphql/queries/get_runner_setup.query.graphql'; +import getRunnerPlatformsQuery from './graphql/get_runner_platforms.query.graphql'; +import { PLATFORM_DOCKER, PLATFORM_KUBERNETES } from './constants'; + +import RunnerCliInstructions from './instructions/runner_cli_instructions.vue'; +import RunnerDockerInstructions from './instructions/runner_docker_instructions.vue'; +import RunnerKubernetesInstructions from './instructions/runner_kubernetes_instructions.vue'; export default { components: { @@ -33,7 +31,7 @@ export default { GlIcon, GlLoadingIcon, GlSkeletonLoader, - ModalCopyButton, + RunnerDockerInstructions, }, directives: { GlResizeObserver: GlResizeObserverDirective, @@ -74,27 +72,13 @@ export default { ); }, result() { - // If it is set and available, select the defaultSelectedPlatform. + // If found, select the defaultSelectedPlatform. // Otherwise, select the first available platform - this.selectPlatform(this.defaultPlatformName || this.platforms?.[0].name); - }, - error() { - this.toggleAlert(true); - }, - }, - instructions: { - query: getRunnerSetupInstructionsQuery, - skip() { - return !this.shown || !this.selectedPlatform; - }, - variables() { - return { - platform: this.selectedPlatform, - architecture: this.selectedArchitecture || '', - }; - }, - update(data) { - return data?.runnerSetup; + const platform = + this.platforms?.find(({ name }) => this.defaultPlatformName === name) || + this.platforms?.[0]; + + this.selectPlatform(platform); }, error() { this.toggleAlert(true); @@ -106,39 +90,23 @@ export default { shown: false, platforms: [], selectedPlatform: null, - selectedArchitecture: null, showAlert: false, - instructions: {}, platformsButtonGroupVertical: false, }; }, computed: { - instructionsEmpty() { - return isEmpty(this.instructions); - }, - architectures() { - return this.platforms.find(({ name }) => name === this.selectedPlatform)?.architectures || []; - }, - binaryUrl() { - return this.architectures.find(({ name }) => name === this.selectedArchitecture) - ?.downloadLocation; - }, - instructionsWithoutArchitecture() { - return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatform]?.instructions; - }, - runnerInstallationLink() { - return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatform]?.link; - }, - registerInstructionsWithToken() { - const { registerInstructions } = this.instructions || {}; - - if (this.registrationToken) { - return registerInstructions?.replace( - REGISTRATION_TOKEN_PLACEHOLDER, - this.registrationToken, - ); + instructionsComponent() { + if (this.selectedPlatform?.architectures?.length) { + return RunnerCliInstructions; + } + switch (this.selectedPlatform?.name) { + case PLATFORM_DOCKER: + return RunnerDockerInstructions; + case PLATFORM_KUBERNETES: + return RunnerKubernetesInstructions; + default: + return null; } - return registerInstructions; }, }, updated() { @@ -149,6 +117,12 @@ export default { show() { this.$refs.modal.show(); }, + close() { + this.$refs.modal.close(); + }, + onClose() { + this.close(); + }, onShown() { this.shown = true; this.refocusSelectedPlatformButton(); @@ -159,21 +133,13 @@ export default { // get focused when setting a `defaultPlatformName`. // This method refocuses the expected button. // See more about this auto-focus: https://bootstrap-vue.org/docs/components/modal#auto-focus-on-open - this.$refs[this.selectedPlatform]?.[0].$el.focus(); + this.$refs[this.selectedPlatform?.name]?.[0].$el.focus(); }, - selectPlatform(platformName) { - this.selectedPlatform = platformName; - - // Update architecture when platform changes - const arch = this.architectures.find(({ name }) => name === this.selectedArchitecture); - if (arch) { - this.selectArchitecture(arch.name); - } else { - this.selectArchitecture(this.architectures[0]?.name); - } + selectPlatform(platform) { + this.selectedPlatform = platform; }, - selectArchitecture(architecture) { - this.selectedArchitecture = architecture; + isPlatformSelected(platform) { + return this.selectedPlatform.name === platform.name; }, toggleAlert(state) { this.showAlert = state; @@ -189,17 +155,9 @@ export default { i18n: { environment: __('Environment'), installARunner: s__('Runners|Install a runner'), - architecture: s__('Runners|Architecture'), downloadInstallBinary: s__('Runners|Download and install binary'), downloadLatestBinary: s__('Runners|Download latest binary'), - registerRunnerCommand: s__('Runners|Command to register runner'), fetchError: s__('Runners|An error has occurred fetching instructions'), - copyInstructions: s__('Runners|Copy instructions'), - viewInstallationInstructions: s__('Runners|View installation instructions'), - }, - closeButton: { - text: __('Close'), - attributes: [{ variant: 'default' }], }, }; </script> @@ -208,8 +166,8 @@ export default { ref="modal" :modal-id="modalId" :title="$options.i18n.installARunner" - :action-secondary="$options.closeButton" v-bind="$attrs" + hide-footer v-on="$listeners" @shown="onShown" > @@ -234,88 +192,23 @@ export default { v-for="platform in platforms" :key="platform.name" :ref="platform.name" - :selected="selectedPlatform === platform.name" - @click="selectPlatform(platform.name)" + :selected="isPlatformSelected(platform)" + @click="selectPlatform(platform)" > {{ platform.humanReadableName }} </gl-button> </gl-button-group> </div> </template> - <template v-if="architectures.length"> - <template v-if="selectedPlatform"> - <h5> - {{ $options.i18n.architecture }} - <gl-loading-icon v-if="$apollo.loading" size="sm" inline /> - </h5> - - <gl-dropdown class="gl-mb-3" :text="selectedArchitecture"> - <gl-dropdown-item - v-for="architecture in architectures" - :key="architecture.name" - is-check-item - :is-checked="selectedArchitecture === architecture.name" - data-testid="architecture-dropdown-item" - @click="selectArchitecture(architecture.name)" - > - {{ architecture.name }} - </gl-dropdown-item> - </gl-dropdown> - <div class="gl-sm-display-flex gl-align-items-center gl-mb-3"> - <h5>{{ $options.i18n.downloadInstallBinary }}</h5> - <gl-button - v-if="binaryUrl" - class="gl-ml-auto" - :href="binaryUrl" - download - icon="download" - data-testid="binary-download-button" - > - {{ $options.i18n.downloadLatestBinary }} - </gl-button> - </div> - </template> - <template v-if="!instructionsEmpty"> - <div class="gl-display-flex"> - <pre - class="gl-bg-gray gl-flex-grow-1 gl-white-space-pre-line" - data-testid="binary-instructions" - >{{ instructions.installInstructions }}</pre - > - <modal-copy-button - :title="$options.i18n.copyInstructions" - :text="instructions.installInstructions" - :modal-id="$options.modalId" - css-classes="gl-align-self-start gl-ml-2 gl-mt-2" - category="tertiary" - /> - </div> - <h5 class="gl-mb-3">{{ $options.i18n.registerRunnerCommand }}</h5> - <div class="gl-display-flex"> - <pre - class="gl-bg-gray gl-flex-grow-1 gl-white-space-pre-line" - data-testid="register-command" - >{{ registerInstructionsWithToken }}</pre - > - <modal-copy-button - :title="$options.i18n.copyInstructions" - :text="registerInstructionsWithToken" - :modal-id="$options.modalId" - css-classes="gl-align-self-start gl-ml-2 gl-mt-2" - category="tertiary" - /> - </div> - </template> - </template> - <template v-else> - <div> - <p>{{ instructionsWithoutArchitecture }}</p> - <gl-button :href="runnerInstallationLink"> - <gl-icon name="external-link" /> - {{ $options.i18n.viewInstallationInstructions }} - </gl-button> - </div> - </template> + <keep-alive> + <component + :is="instructionsComponent" + :registration-token="registrationToken" + :platform="selectedPlatform" + @close="onClose" + @error="toggleAlert(true)" + /> + </keep-alive> </gl-modal> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js index a28460dd58e..f382ded90d7 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js @@ -140,3 +140,7 @@ export const BIDI_CHARS_CLASS_LIST = 'unicode-bidi has-tooltip'; export const BIDI_CHAR_TOOLTIP = 'Potentially unwanted character detected: Unicode BiDi Control'; export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight'; + +// We fallback to highlighting these languages with Rouge, see the following issue for more detail: +// https://gitlab.com/gitlab-org/gitlab/-/issues/384375#note_1212752013 +export const LEGACY_FALLBACKS = ['python']; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue index 0cfee93ce5d..efafa67a733 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue @@ -11,6 +11,7 @@ import { EVENT_LABEL_FALLBACK, ROUGE_TO_HLJS_LANGUAGE_MAP, LINES_PER_CHUNK, + LEGACY_FALLBACKS, } from './constants'; import Chunk from './components/chunk.vue'; import { registerPlugins } from './plugins/index'; @@ -57,10 +58,11 @@ export default { }, unsupportedLanguage() { const supportedLanguages = Object.keys(languageLoader); - return ( + const unsupportedLanguage = !supportedLanguages.includes(this.language) && - !supportedLanguages.includes(this.blob.language?.toLowerCase()) - ); + !supportedLanguages.includes(this.blob.language?.toLowerCase()); + + return LEGACY_FALLBACKS.includes(this.language) || unsupportedLanguage; }, totalChunks() { return Object.keys(this.chunks).length; diff --git a/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue b/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue index 423501265d7..247f49c1345 100644 --- a/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue @@ -30,6 +30,11 @@ export default { required: true, default: () => [], }, + additionalClass: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { @@ -96,7 +101,14 @@ export default { :value="timezoneIdentifier || value" type="hidden" /> - <gl-dropdown :text="selectedTimezoneLabel" block lazy menu-class="gl-w-full!" v-bind="$attrs"> + <gl-dropdown + :text="selectedTimezoneLabel" + :class="additionalClass" + block + lazy + menu-class="gl-w-full!" + v-bind="$attrs" + > <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus /> <gl-dropdown-item v-for="timezone in filteredResults" 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 383dc27ea5e..98630512308 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -1,16 +1,13 @@ <script> import { GlModal, GlSprintf, GlLink, GlPopover } from '@gitlab/ui'; import { s__, __ } from '~/locale'; +import { visitUrl } from '~/lib/utils/url_utility'; import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; import ActionsButton from '~/vue_shared/components/actions_button.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import ConfirmForkModal from '~/vue_shared/components/confirm_fork_modal.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; - -export const KEY_EDIT = 'edit'; -export const KEY_WEB_IDE = 'webide'; -export const KEY_GITPOD = 'gitpod'; -export const KEY_PIPELINE_EDITOR = 'pipeline_editor'; +import { KEY_EDIT, KEY_WEB_IDE, KEY_GITPOD, KEY_PIPELINE_EDITOR } from './constants'; export const i18n = { modal: { @@ -221,7 +218,13 @@ export default { this.showModal('showForkModal'); }, } - : { href: this.webIdeUrl }; + : { + href: this.webIdeUrl, + handle: (evt) => { + evt.preventDefault(); + visitUrl(this.webIdeUrl, true); + }, + }; return { key: KEY_WEB_IDE, diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js index 2f85a29fb84..c93dd95a886 100644 --- a/app/assets/javascripts/vue_shared/constants.js +++ b/app/assets/javascripts/vue_shared/constants.js @@ -9,7 +9,7 @@ const INTERVALS = { export const FILE_SYMLINK_MODE = '120000'; -export const SHORT_DATE_FORMAT = 'd mmm, yyyy'; +export const SHORT_DATE_FORMAT = 'mmm dd, yyyy'; export const ISO_SHORT_FORMAT = 'yyyy-mm-dd'; diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue index 2fc1f935501..387fc5e0d1c 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue @@ -1,6 +1,5 @@ <script> import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui'; -import $ from 'jquery'; import Autosave from '~/autosave'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; @@ -81,13 +80,13 @@ export default { if (!titleInput || !descriptionInput) return; - this.autosaveTitle = new Autosave($(titleInput.$el), [ + this.autosaveTitle = new Autosave(titleInput.$el, [ document.location.pathname, document.location.search, 'title', ]); - this.autosaveDescription = new Autosave($(descriptionInput.$el), [ + this.autosaveDescription = new Autosave(descriptionInput, [ document.location.pathname, document.location.search, 'description', 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 b6a459f21e0..26309a25f07 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue @@ -34,7 +34,7 @@ export default { :href="`#${panel.name}`" data-qa-selector="panel_link" :data-qa-panel-name="panel.name" - class="new-namespace-panel gl-display-flex gl-flex-shrink-0 gl-flex-direction-column gl-lg-flex-direction-row gl-align-items-center gl-rounded-base gl-border-gray-100 gl-border-solid gl-border-1 gl-w-full gl-py-6 gl-px-8 gl-hover-text-decoration-none!" + class="new-namespace-panel gl-display-flex gl-flex-shrink-0 gl-flex-direction-column gl-lg-flex-direction-row gl-align-items-center gl-rounded-base gl-border-gray-100 gl-border-solid gl-border-1 gl-w-full gl-py-6 gl-px-3 gl-hover-text-decoration-none!" @click="track('click_tab', { label: panel.name })" > <div diff --git a/app/assets/javascripts/work_items/components/notes/activity_filter.vue b/app/assets/javascripts/work_items/components/notes/activity_filter.vue new file mode 100644 index 00000000000..71784d3a807 --- /dev/null +++ b/app/assets/javascripts/work_items/components/notes/activity_filter.vue @@ -0,0 +1,113 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { __ } from '~/locale'; +import Tracking from '~/tracking'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import { ASC, DESC } from '~/notes/constants'; +import { TRACKING_CATEGORY_SHOW, WORK_ITEM_NOTES_SORT_ORDER_KEY } from '~/work_items/constants'; + +const SORT_OPTIONS = [ + { key: DESC, text: __('Newest first'), dataid: 'js-newest-first' }, + { key: ASC, text: __('Oldest first'), dataid: 'js-oldest-first' }, +]; + +export default { + SORT_OPTIONS, + components: { + GlDropdown, + GlDropdownItem, + LocalStorageSync, + }, + mixins: [Tracking.mixin()], + props: { + sortOrder: { + type: String, + default: ASC, + required: false, + }, + loading: { + type: Boolean, + default: false, + required: false, + }, + workItemType: { + type: String, + required: true, + }, + }, + data() { + return { + persistSortOrder: true, + }; + }, + computed: { + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'item_track_notes_sorting', + property: `type_${this.workItemType}`, + }; + }, + selectedSortOption() { + const isSortOptionValid = this.sortOrder === ASC || this.sortOrder === DESC; + return isSortOptionValid ? SORT_OPTIONS.find(({ key }) => this.sortOrder === key) : ASC; + }, + getDropdownSelectedText() { + return this.selectedSortOption.text; + }, + }, + methods: { + setDiscussionSortDirection(direction) { + this.$emit('updateSavedSortOrder', direction); + }, + fetchSortedDiscussions(direction) { + if (this.isSortDropdownItemActive(direction)) { + return; + } + this.track('notes_sort_order_changed'); + this.$emit('changeSortOrder', direction); + }, + isSortDropdownItemActive(sortDir) { + return sortDir === this.sortOrder; + }, + }, + WORK_ITEM_NOTES_SORT_ORDER_KEY, +}; +</script> + +<template> + <div + id="discussion-preferences" + data-testid="discussion-preferences" + class="gl-display-inline-block gl-vertical-align-bottom gl-w-full gl-sm-w-auto" + > + <local-storage-sync + :value="sortOrder" + :storage-key="$options.WORK_ITEM_NOTES_SORT_ORDER_KEY" + :persist="persistSortOrder" + as-string + @input="setDiscussionSortDirection" + /> + <gl-dropdown + :id="`discussion-preferences-dropdown-${workItemType}`" + class="gl-xs-w-full" + size="small" + :text="getDropdownSelectedText" + :disabled="loading" + right + > + <div id="discussion-sort"> + <gl-dropdown-item + v-for="{ text, key, dataid } in $options.SORT_OPTIONS" + :key="text" + :data-testid="dataid" + is-check-item + :is-checked="isSortDropdownItemActive(key)" + @click="fetchSortedDiscussions(key)" + > + {{ text }} + </gl-dropdown-item> + </div> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_note.vue new file mode 100644 index 00000000000..5efa9c94f2b --- /dev/null +++ b/app/assets/javascripts/work_items/components/notes/work_item_note.vue @@ -0,0 +1,59 @@ +<script> +import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; +import NoteBody from '~/work_items/components/notes/work_item_note_body.vue'; +import NoteHeader from '~/notes/components/note_header.vue'; + +export default { + components: { + NoteHeader, + NoteBody, + TimelineEntryItem, + GlAvatarLink, + GlAvatar, + }, + props: { + note: { + type: Object, + required: true, + }, + }, + computed: { + author() { + return this.note.author; + }, + noteAnchorId() { + return `note_${this.note.id}`; + }, + }, +}; +</script> + +<template> + <timeline-entry-item + :id="noteAnchorId" + :class="{ 'internal-note': note.internal }" + :data-note-id="note.id" + class="note note-wrapper note-comment" + > + <div class="timeline-avatar gl-float-left"> + <gl-avatar-link :href="author.webUrl"> + <gl-avatar + :src="author.avatarUrl" + :entity-name="author.username" + :alt="author.name" + :size="32" + /> + </gl-avatar-link> + </div> + + <div class="timeline-content"> + <div class="note-header"> + <note-header :author="author" :created-at="note.createdAt" :note-id="note.id" /> + </div> + <div class="timeline-discussion-body"> + <note-body :note="note" /> + </div> + </div> + </timeline-entry-item> +</template> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue new file mode 100644 index 00000000000..dcee8750f81 --- /dev/null +++ b/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue @@ -0,0 +1,37 @@ +<script> +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; + +export default { + directives: { + SafeHtml, + }, + props: { + note: { + type: Object, + required: true, + }, + }, + mounted() { + this.renderGFM(); + }, + methods: { + renderGFM() { + renderGFM(this.$refs['note-body']); + }, + }, + safeHtmlConfig: { + ADD_TAGS: ['use', 'gl-emoji', 'copy-code'], + }, +}; +</script> + +<template> + <div ref="note-body" class="note-body"> + <div + v-safe-html:[$options.safeHtmlConfig]="note.bodyHtml" + class="note-text md" + data-testid="work-item-note-body" + ></div> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/work_item_comment_form.vue new file mode 100644 index 00000000000..65042f1431d --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_comment_form.vue @@ -0,0 +1,228 @@ +<script> +import { GlAvatar, GlButton } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import { __, s__ } from '~/locale'; +import Tracking from '~/tracking'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; +import { getWorkItemQuery, getWorkItemNotesQuery } from '../utils'; +import createNoteMutation from '../graphql/create_work_item_note.mutation.graphql'; +import { i18n, TRACKING_CATEGORY_SHOW } from '../constants'; +import WorkItemNoteSignedOut from './work_item_note_signed_out.vue'; +import WorkItemCommentLocked from './work_item_comment_locked.vue'; + +export default { + constantOptions: { + markdownDocsPath: helpPagePath('user/markdown'), + avatarUrl: window.gon.current_user_avatar_url, + }, + components: { + GlAvatar, + GlButton, + MarkdownEditor, + WorkItemNoteSignedOut, + WorkItemCommentLocked, + }, + mixins: [glFeatureFlagMixin(), Tracking.mixin()], + props: { + workItemId: { + type: String, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + fetchByIid: { + type: Boolean, + required: false, + default: false, + }, + queryVariables: { + type: Object, + required: true, + }, + }, + data() { + return { + workItem: {}, + isEditing: false, + isSubmitting: false, + isSubmittingWithKeydown: false, + commentText: '', + }; + }, + apollo: { + workItem: { + query() { + return getWorkItemQuery(this.fetchByIid); + }, + variables() { + return this.queryVariables; + }, + update(data) { + return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; + }, + skip() { + return !this.queryVariables.id && !this.queryVariables.iid; + }, + error() { + this.$emit('error', i18n.fetchError); + }, + }, + }, + computed: { + signedIn() { + return Boolean(window.gon.current_user_id); + }, + autosaveKey() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `${this.workItemId}-comment`; + }, + canEdit() { + // maybe this should use `NotePermissions.updateNote`, but if + // we don't have any notes yet, that permission isn't on WorkItem + return Boolean(this.workItem?.userPermissions?.updateWorkItem); + }, + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'item_comment', + property: `type_${this.workItemType}`, + }; + }, + workItemType() { + return this.workItem?.workItemType?.name; + }, + markdownPreviewPath() { + return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${ + this.workItemType + }`; + }, + isProjectArchived() { + return this.workItem?.project?.archived; + }, + }, + methods: { + startEditing() { + this.isEditing = true; + this.commentText = getDraft(this.autosaveKey) || ''; + }, + async cancelEditing() { + if (this.commentText) { + const msg = s__('WorkItem|Are you sure you want to cancel editing?'); + + const confirmed = await confirmAction(msg, { + primaryBtnText: __('Discard changes'), + cancelBtnText: __('Continue editing'), + }); + + if (!confirmed) { + return; + } + } + + this.isEditing = false; + clearDraft(this.autosaveKey); + }, + async updateWorkItem(event = {}) { + const { key } = event; + + if (key) { + this.isSubmittingWithKeydown = true; + } + + this.isSubmitting = true; + + try { + this.track('add_work_item_comment'); + + const { + data: { createNote }, + } = await this.$apollo.mutate({ + mutation: createNoteMutation, + variables: { + input: { + noteableId: this.workItem.id, + body: this.commentText, + }, + }, + }); + + if (createNote.errors?.length) { + throw new Error(createNote.errors[0]); + } + + const client = this.$apollo.provider.defaultClient; + client.refetchQueries({ + include: [getWorkItemNotesQuery(this.fetchByIid)], + }); + + this.isEditing = false; + clearDraft(this.autosaveKey); + } catch (error) { + this.$emit('error', error.message); + Sentry.captureException(error); + } + + this.isSubmitting = false; + }, + setCommentText(newText) { + this.commentText = newText; + updateDraft(this.autosaveKey, this.commentText); + }, + }, +}; +</script> + +<template> + <li class="timeline-entry"> + <work-item-note-signed-out v-if="!signedIn" /> + <work-item-comment-locked + v-else-if="!canEdit" + :work-item-type="workItemType" + :is-project-archived="isProjectArchived" + /> + <div v-else class="gl-display-flex gl-align-items-flex-start gl-flex-wrap-nowrap"> + <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" /> + <form v-if="isEditing" class="common-note-form gfm-form js-main-target-form gl-flex-grow-1"> + <markdown-editor + class="gl-mb-3" + :value="commentText" + :render-markdown-path="markdownPreviewPath" + :markdown-docs-path="$options.constantOptions.markdownDocsPath" + :form-field-aria-label="__('Add a comment')" + :form-field-placeholder="__('Write a comment or drag your files here…')" + form-field-id="work-item-add-comment" + form-field-name="work-item-add-comment" + enable-autocomplete + autofocus + use-bottom-toolbar + @input="setCommentText" + @keydown.meta.enter="updateWorkItem" + @keydown.ctrl.enter="updateWorkItem" + @keydown.esc="cancelEditing" + /> + <gl-button + category="primary" + variant="confirm" + :loading="isSubmitting" + @click="updateWorkItem" + >{{ __('Comment') }} + </gl-button> + <gl-button category="tertiary" class="gl-ml-3" @click="cancelEditing" + >{{ __('Cancel') }} + </gl-button> + </form> + <gl-button + v-else + class="gl-flex-grow-1 gl-justify-content-start! gl-text-secondary!" + @click="startEditing" + >{{ __('Add a comment') }}</gl-button + > + </div> + </li> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_comment_locked.vue b/app/assets/javascripts/work_items/components/work_item_comment_locked.vue new file mode 100644 index 00000000000..f837d025b7f --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_comment_locked.vue @@ -0,0 +1,66 @@ +<script> +import { GlLink, GlIcon } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { TASK_TYPE_NAME } from '~/work_items/constants'; + +export default { + components: { + GlIcon, + GlLink, + }, + props: { + workItemType: { + required: false, + type: String, + default: TASK_TYPE_NAME, + }, + isProjectArchived: { + required: false, + type: Boolean, + default: false, + }, + }, + constantOptions: { + archivedProjectDocsPath: helpPagePath('user/project/settings/index.md', { + anchor: 'archive-a-project', + }), + lockedIssueDocsPath: helpPagePath('user/discussions/index.md', { + anchor: 'prevent-comments-by-locking-the-discussion', + }), + projectArchivedWarning: __('This project is archived and cannot be commented on.'), + }, + computed: { + issuableDisplayName() { + return this.workItemType.replace(/_/g, ' '); + }, + lockedIssueWarning() { + return sprintf( + __('This %{issuableDisplayName} is locked. Only project members can comment.'), + { issuableDisplayName: this.issuableDisplayName }, + ); + }, + }, +}; +</script> + +<template> + <div class="disabled-comment text-center"> + <span class="issuable-note-warning gl-display-inline-block"> + <gl-icon name="lock" class="gl-mr-2" /> + <template v-if="isProjectArchived"> + {{ $options.constantOptions.projectArchivedWarning }} + <gl-link :href="$options.constantOptions.archivedProjectDocsPath" class="learn-more"> + {{ __('Learn more') }} + </gl-link> + </template> + + <template v-else> + {{ lockedIssueWarning }} + <gl-link :href="$options.constantOptions.lockedIssueDocsPath" class="learn-more"> + {{ __('Learn more') }} + </gl-link> + </template> + </span> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index cb45a05de89..ade954b2a7f 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -15,10 +15,14 @@ import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg import * as Sentry from '@sentry/browser'; import { s__ } from '~/locale'; import { parseBoolean } from '~/lib/utils/common_utils'; -import { getParameterByName } from '~/lib/utils/url_utility'; +import { getParameterByName, updateHistory, setUrlParams } from '~/lib/utils/url_utility'; +import { isPositiveInteger } from '~/lib/utils/number_utils'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import { + sprintfWorkItem, i18n, WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS, @@ -53,6 +57,7 @@ import WorkItemAssignees from './work_item_assignees.vue'; import WorkItemLabels from './work_item_labels.vue'; import WorkItemMilestone from './work_item_milestone.vue'; import WorkItemNotes from './work_item_notes.vue'; +import WorkItemDetailModal from './work_item_detail_modal.vue'; export default { i18n, @@ -83,6 +88,7 @@ export default { WorkItemMilestone, WorkItemTree, WorkItemNotes, + WorkItemDetailModal, }, mixins: [glFeatureFlagMixin()], inject: ['fullPath'], @@ -109,11 +115,16 @@ export default { }, }, data() { + const workItemId = getParameterByName('work_item_id'); + return { error: undefined, updateError: undefined, workItem: {}, updateInProgress: false, + modalWorkItemId: isPositiveInteger(workItemId) + ? convertToGraphQLId(TYPE_WORK_ITEM, workItemId) + : null, }; }, apollo: { @@ -207,6 +218,9 @@ export default { canDelete() { return this.workItem?.userPermissions?.deleteWorkItem; }, + confidentialTooltip() { + return sprintfWorkItem(this.$options.i18n.confidentialTooltip, this.workItemType); + }, fullPath() { return this.workItem?.project.fullPath; }, @@ -295,6 +309,11 @@ export default { return widgetHierarchy.children.nodes; }, }, + mounted() { + if (this.modalWorkItemId) { + this.openInModal(undefined, { id: this.modalWorkItemId }); + } + }, methods: { isWidgetPresent(type) { return this.workItem?.widgets?.find((widget) => widget.type === type); @@ -362,9 +381,10 @@ export default { }); const newData = produce(sourceData, (draftState) => { - const widgetHierarchy = draftState.workItem.widgets.find( - (widget) => widget.type === WIDGET_TYPE_HIERARCHY, - ); + const widgets = this.fetchByIid + ? draftState.workspace.workItems.nodes[0].widgets + : draftState.workItem.widgets; + const widgetHierarchy = widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY); const index = widgetHierarchy.children.nodes.findIndex((child) => child.id === childId); @@ -419,6 +439,26 @@ export default { Sentry.captureException(error); } }, + updateUrl(modalWorkItemId) { + updateHistory({ + url: setUrlParams({ work_item_id: getIdFromGraphQLId(modalWorkItemId) }), + replace: true, + }); + }, + openInModal(event, modalWorkItem) { + if (event) { + event.preventDefault(); + + this.updateUrl(modalWorkItem.id); + } + + if (this.isModal) { + this.$emit('update-modal', event, modalWorkItem.id); + return; + } + this.modalWorkItemId = modalWorkItem.id; + this.$refs.modal.show(); + }, }, WORK_ITEM_TYPE_VALUE_OBJECTIVE, }; @@ -456,6 +496,7 @@ export default { category="tertiary" :href="parentUrl" :title="parentWorkItem.title" + @click="openInModal($event, parentWorkItem)" >{{ parentWorkItem.title }}</gl-button > <gl-icon name="chevron-right" :size="16" class="gl-flex-shrink-0" /> @@ -482,7 +523,7 @@ export default { <gl-badge v-if="workItem.confidential" v-gl-tooltip.bottom - :title="$options.i18n.confidentialTooltip" + :title="confidentialTooltip" variant="warning" icon="eye-slash" class="gl-mr-3 gl-cursor-help" @@ -605,6 +646,9 @@ export default { :can-update="canUpdate" :work-item-id="workItem.id" :work-item-type="workItemType" + :fetch-by-iid="fetchByIid" + :query-variables="queryVariables" + :full-path="fullPath" @error="updateError = $event" /> <work-item-description @@ -619,20 +663,24 @@ export default { <work-item-tree v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE" :work-item-type="workItemType" + :parent-work-item-type="workItem.workItemType.name" :work-item-id="workItem.id" :children="children" :can-update="canUpdate" :project-path="fullPath" + :confidential="workItem.confidential" @addWorkItemChild="addChild" @removeChild="removeChild" + @show-modal="openInModal" /> - <template v-if="workItemsMvc2Enabled"> + <template v-if="workItemsMvcEnabled"> <work-item-notes v-if="workItemNotes" :work-item-id="workItem.id" :query-variables="queryVariables" :full-path="fullPath" :fetch-by-iid="fetchByIid" + :work-item-type="workItemType" class="gl-pt-5" @error="updateError = $event" /> @@ -644,5 +692,12 @@ export default { :svg-path="noAccessSvgPath" /> </template> + <work-item-detail-modal + v-if="!isModal" + ref="modal" + :work-item-id="modalWorkItemId" + :show="true" + @close="updateUrl" + /> </section> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue index e8726814aaf..faea80a9de8 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue @@ -3,7 +3,6 @@ import { GlAlert, GlModal } from '@gitlab/ui'; import { s__ } from '~/locale'; import deleteWorkItemFromTaskMutation from '../graphql/delete_task_from_work_item.mutation.graphql'; import deleteWorkItemMutation from '../graphql/delete_work_item.mutation.graphql'; -import WorkItemDetail from './work_item_detail.vue'; export default { i18n: { @@ -12,7 +11,7 @@ export default { components: { GlAlert, GlModal, - WorkItemDetail, + WorkItemDetail: () => import('./work_item_detail.vue'), }, props: { workItemId: { @@ -46,12 +45,18 @@ export default { default: null, }, }, - emits: ['workItemDeleted', 'close'], + emits: ['workItemDeleted', 'close', 'update-modal'], data() { return { error: undefined, + updatedWorkItemId: null, }; }, + computed: { + displayedWorkItemId() { + return this.updatedWorkItemId || this.workItemId; + }, + }, methods: { deleteWorkItem() { if (this.lockVersion != null && this.lineNumberStart && this.lineNumberEnd) { @@ -116,6 +121,7 @@ export default { }); }, closeModal() { + this.updatedWorkItemId = null; this.error = ''; this.$emit('close'); }, @@ -128,6 +134,10 @@ export default { show() { this.$refs.modal.show(); }, + updateModal($event, workItemId) { + this.updatedWorkItemId = workItemId; + this.$emit('update-modal', $event, workItemId); + }, }, }; </script> @@ -149,11 +159,12 @@ export default { <work-item-detail is-modal :work-item-parent-id="issueGid" - :work-item-id="workItemId" + :work-item-id="displayedWorkItemId" :work-item-iid="workItemIid" - class="gl-p-5 gl-mt-n3" + class="gl-p-5 gl-mt-n3 gl-reset-bg gl-isolate" @close="hide" @deleteWorkItem="deleteWorkItem" + @update-modal="updateModal" /> </gl-modal> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js index edad0e9b616..a7405b6d86c 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/index.js +++ b/app/assets/javascripts/work_items/components/work_item_links/index.js @@ -18,6 +18,8 @@ export default function initWorkItemLinks() { iid, wiHasIterationsFeature, wiHasIssuableHealthStatusFeature, + registerPath, + signInPath, } = workItemLinksRoot.dataset; // eslint-disable-next-line no-new @@ -35,6 +37,8 @@ export default function initWorkItemLinks() { hasIssueWeightsFeature: wiHasIssueWeightsFeature, hasIterationsFeature: wiHasIterationsFeature, hasIssuableHealthStatusFeature: wiHasIssuableHealthStatusFeature, + registerPath, + signInPath, }, render: (createElement) => createElement('work-item-links', { diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue index 763f2f338a3..3a3a846bce5 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue @@ -5,11 +5,14 @@ import { __, s__ } from '~/locale'; import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; +import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/work_item_links/work_item_link_child_metadata.vue'; import { STATE_OPEN, TASK_TYPE_NAME, WORK_ITEM_TYPE_VALUE_OBJECTIVE, + WIDGET_TYPE_PROGRESS, + WIDGET_TYPE_HEALTH_STATUS, WIDGET_TYPE_MILESTONE, WIDGET_TYPE_HIERARCHY, WIDGET_TYPE_ASSIGNEES, @@ -17,7 +20,6 @@ import { WORK_ITEM_NAME_TO_ICON_MAP, } from '../../constants'; import getWorkItemTreeQuery from '../../graphql/work_item_tree.query.graphql'; -import WorkItemLinkChildMetadata from './work_item_link_child_metadata.vue'; import WorkItemLinksMenu from './work_item_links_menu.vue'; import WorkItemTreeChildren from './work_item_tree_children.vue'; @@ -73,8 +75,15 @@ export default { canHaveChildren() { return this.workItemType === WORK_ITEM_TYPE_VALUE_OBJECTIVE; }, - allowsScopedLabels() { - return this.getWidgetByType(this.childItem, WIDGET_TYPE_LABELS)?.allowsScopedLabels; + metadataWidgets() { + return this.childItem.widgets?.reduce((metadataWidgets, widget) => { + // Skip Hierarchy widget as it is not part of metadata. + if (widget.type && widget.type !== WIDGET_TYPE_HIERARCHY) { + // eslint-disable-next-line no-param-reassign + metadataWidgets[widget.type] = widget; + } + return metadataWidgets; + }, {}); }, isItemOpen() { return this.childItem.state === STATE_OPEN; @@ -113,16 +122,16 @@ export default { return this.isExpanded ? __('Collapse') : __('Expand'); }, hasMetadata() { - return this.milestone || this.assignees.length > 0 || this.labels.length > 0; - }, - milestone() { - return this.getWidgetByType(this.childItem, WIDGET_TYPE_MILESTONE)?.milestone; - }, - assignees() { - return this.getWidgetByType(this.childItem, WIDGET_TYPE_ASSIGNEES)?.assignees?.nodes || []; - }, - labels() { - return this.getWidgetByType(this.childItem, WIDGET_TYPE_LABELS)?.labels?.nodes || []; + if (this.metadataWidgets) { + return ( + Number.isInteger(this.metadataWidgets[WIDGET_TYPE_PROGRESS]?.progress) || + Boolean(this.metadataWidgets[WIDGET_TYPE_HEALTH_STATUS]?.healthStatus) || + Boolean(this.metadataWidgets[WIDGET_TYPE_MILESTONE]?.milestone) || + this.metadataWidgets[WIDGET_TYPE_ASSIGNEES]?.assignees?.nodes.length > 0 || + this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes.length > 0 + ); + } + return false; }, }, methods: { @@ -230,10 +239,7 @@ export default { </div> <work-item-link-child-metadata v-if="hasMetadata" - :allows-scoped-labels="allowsScopedLabels" - :milestone="milestone" - :assignees="assignees" - :labels="labels" + :metadata-widgets="metadataWidgets" class="gl-mt-3" /> </div> @@ -258,6 +264,7 @@ export default { :work-item-type="workItemType" :children="children" @removeChild="fetchChildren" + @click="$emit('click', $event)" /> </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue index 7be7e1f3496..6974804523a 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue @@ -6,6 +6,8 @@ import { isScopedLabel } from '~/lib/utils/common_utils'; import ItemMilestone from '~/issuable/components/issue_milestone.vue'; +import { WIDGET_TYPE_MILESTONE, WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS } from '../../constants'; + export default { components: { GlLabel, @@ -18,28 +20,25 @@ export default { GlTooltip: GlTooltipDirective, }, props: { - allowsScopedLabels: { - type: Boolean, - required: false, - default: false, - }, - milestone: { + metadataWidgets: { type: Object, required: false, - default: null, - }, - assignees: { - type: Array, - required: false, - default: () => [], - }, - labels: { - type: Array, - required: false, - default: () => [], + default: () => ({}), }, }, computed: { + milestone() { + return this.metadataWidgets[WIDGET_TYPE_MILESTONE]?.milestone; + }, + assignees() { + return this.metadataWidgets[WIDGET_TYPE_ASSIGNEES]?.assignees?.nodes || []; + }, + labels() { + return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || []; + }, + allowsScopedLabels() { + return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels; + }, assigneesCollapsedTooltip() { if (this.assignees.length > 2) { return sprintf(s__('WorkItem|%{count} more assignees'), { @@ -56,12 +55,6 @@ export default { } return ''; }, - labelsContainerClass() { - if (this.milestone || this.assignees.length) { - return 'gl-sm-ml-5'; - } - return ''; - }, }, methods: { showScopedLabel(label) { @@ -73,6 +66,7 @@ export default { <template> <div class="gl-display-flex gl-flex-wrap gl-align-items-center"> + <slot></slot> <item-milestone v-if="milestone" :milestone="milestone" @@ -87,6 +81,7 @@ export default { badge-tooltip-prop="name" :badge-sr-only-text="assigneesCollapsedTooltip" :class="assigneesContainerClass" + class="gl-mr-5" > <template #avatar="{ avatar }"> <gl-avatar-link v-gl-tooltip target="blank" :href="avatar.webUrl" :title="avatar.name"> @@ -94,7 +89,7 @@ export default { </gl-avatar-link> </template> </gl-avatars-inline> - <div v-if="labels.length" class="gl-display-flex gl-flex-wrap" :class="labelsContainerClass"> + <div v-if="labels.length" class="gl-display-flex gl-flex-wrap"> <gl-label v-for="label in labels" :key="label.id" @@ -102,7 +97,7 @@ export default { :background-color="label.color" :description="label.description" :scoped="showScopedLabel(label)" - class="gl-mt-2 gl-sm-mt-0 gl-mr-2 gl-mb-auto gl-label-sm" + class="gl-mt-3 gl-sm-mt-0 gl-mr-2 gl-mb-auto gl-label-sm" tooltip-placement="top" /> </div> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue index faadb5fa6fa..b078711ec5d 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue @@ -399,6 +399,7 @@ export default { :parent-iteration="issuableIteration" :parent-milestone="issuableMilestone" :form-type="formType" + :parent-work-item-type="workItem.workItemType.name" @cancel="hideAddForm" @addWorkItemChild="addChild" /> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue index 5cf0c4154bb..d79aaab38f2 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue @@ -1,9 +1,18 @@ <script> -import { GlAlert, GlFormGroup, GlForm, GlTokenSelector, GlButton, GlFormInput } from '@gitlab/ui'; +import { + GlAlert, + GlFormGroup, + GlForm, + GlTokenSelector, + GlButton, + GlFormInput, + GlFormCheckbox, + GlTooltip, +} from '@gitlab/ui'; import { debounce } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { __, s__ } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql'; @@ -17,6 +26,8 @@ import { I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, I18N_WORK_ITEM_ADD_BUTTON_LABEL, I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL, + I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL, + I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_TOOLTIP, sprintfWorkItem, } from '../../constants'; @@ -28,6 +39,8 @@ export default { GlButton, GlFormGroup, GlFormInput, + GlFormCheckbox, + GlTooltip, }, mixins: [glFeatureFlagMixin()], inject: ['projectPath', 'hasIterationsFeature'], @@ -61,6 +74,11 @@ export default { type: String, required: true, }, + parentWorkItemType: { + type: String, + required: false, + default: '', + }, childrenType: { type: String, required: false, @@ -108,6 +126,7 @@ export default { error: null, childToCreateTitle: null, workItemsToAdd: [], + confidential: this.parentConfidential, }; }, computed: { @@ -119,7 +138,7 @@ export default { hierarchyWidget: { parentId: this.issuableGid, }, - confidential: this.parentConfidential, + confidential: this.parentConfidential || this.confidential, }; if (this.parentMilestoneId) { @@ -154,6 +173,9 @@ export default { childrenTypeName() { return WORK_ITEMS_TYPE_MAP[this.childrenType]?.name; }, + childrenTypeValue() { + return WORK_ITEMS_TYPE_MAP[this.childrenType]?.value; + }, addOrCreateButtonLabel() { if (this.isCreateForm) { return sprintfWorkItem(I18N_WORK_ITEM_CREATE_BUTTON_LABEL, this.childrenTypeName); @@ -162,11 +184,24 @@ export default { } return sprintfWorkItem(I18N_WORK_ITEM_ADD_BUTTON_LABEL, this.childrenTypeName); }, + confidentialityCheckboxLabel() { + return sprintfWorkItem(I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL, this.childrenTypeName); + }, + confidentialityCheckboxTooltip() { + return sprintfWorkItem( + I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_TOOLTIP, + this.childrenTypeName, + this.parentWorkItemType, + ); + }, + showConfidentialityTooltip() { + return this.isCreateForm && this.parentConfidential; + }, addOrCreateMethod() { return this.isCreateForm ? this.createChild : this.addChild; }, childWorkItemType() { - return this.workItemTypes.find((type) => type.name === this.childrenTypeName)?.id; + return this.workItemTypes.find((type) => type.name === this.childrenTypeValue)?.id; }, parentIterationId() { return this.parentIteration?.id; @@ -178,7 +213,10 @@ export default { return this.parentMilestone?.id; }, isSubmitButtonDisabled() { - return this.isCreateForm ? this.search.length === 0 : this.workItemsToAdd.length === 0; + if (this.isCreateForm) { + return this.search.length === 0; + } + return this.workItemsToAdd.length === 0 || !this.areWorkItemsToAddValid; }, isLoading() { return this.$apollo.queries.availableWorkItems.loading; @@ -186,12 +224,43 @@ export default { addInputPlaceholder() { return sprintfWorkItem(I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, this.childrenTypeName); }, + tokenSelectorContainerClass() { + return !this.areWorkItemsToAddValid ? 'gl-inset-border-1-red-500!' : ''; + }, + invalidWorkItemsToAdd() { + return this.parentConfidential + ? this.workItemsToAdd.filter((workItem) => !workItem.confidential) + : []; + }, + areWorkItemsToAddValid() { + return this.invalidWorkItemsToAdd.length === 0; + }, + showWorkItemsToAddInvalidMessage() { + return !this.isCreateForm && !this.areWorkItemsToAddValid; + }, + workItemsToAddInvalidMessage() { + return sprintf( + s__( + 'WorkItem|%{invalidWorkItemsList} cannot be added: Cannot assign a non-confidential %{childWorkItemType} to a confidential parent %{parentWorkItemType}. Make the selected %{childWorkItemType} confidential and try again.', + ), + { + invalidWorkItemsList: this.invalidWorkItemsToAdd.map(({ title }) => title).join(', '), + childWorkItemType: this.childrenTypeName, + parentWorkItemType: this.parentWorkItemType, + }, + ); + }, }, created() { this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); }, methods: { getIdFromGraphQLId, + getConfidentialityTooltipTarget() { + // We want tooltip to be anchored to `input` within checkbox component + // but `$el.querySelector('input')` doesn't work. 🤷♂️ + return this.$refs.confidentialityCheckbox?.$el; + }, unsetError() { this.error = null; }, @@ -299,30 +368,54 @@ export default { autofocus /> </gl-form-group> - <gl-token-selector - v-else - v-model="workItemsToAdd" - :dropdown-items="availableWorkItems" - :loading="isLoading" - :placeholder="addInputPlaceholder" - menu-class="gl-dropdown-menu-wide dropdown-reduced-height gl-min-h-7!" - class="gl-mb-4" - data-testid="work-item-token-select-input" - @text-input="debouncedSearchKeyUpdate" - @focus="handleFocus" - @mouseover.native="handleMouseOver" - @mouseout.native="handleMouseOut" + <gl-form-checkbox + v-if="isCreateForm" + ref="confidentialityCheckbox" + v-model="confidential" + name="isConfidential" + class="gl-md-mt-5 gl-mb-5 gl-md-mb-3!" + :disabled="parentConfidential" + >{{ confidentialityCheckboxLabel }}</gl-form-checkbox + > + <gl-tooltip + v-if="showConfidentialityTooltip" + :target="getConfidentialityTooltipTarget" + triggers="hover" + >{{ confidentialityCheckboxTooltip }}</gl-tooltip > - <template #token-content="{ token }"> - {{ token.title }} - </template> - <template #dropdown-item-content="{ dropdownItem }"> - <div class="gl-display-flex"> - <div class="gl-text-secondary gl-mr-4">{{ getIdFromGraphQLId(dropdownItem.id) }}</div> - <div class="gl-text-truncate">{{ dropdownItem.title }}</div> - </div> - </template> - </gl-token-selector> + <div class="gl-mb-4"> + <gl-token-selector + v-if="!isCreateForm" + v-model="workItemsToAdd" + :dropdown-items="availableWorkItems" + :loading="isLoading" + :placeholder="addInputPlaceholder" + menu-class="gl-dropdown-menu-wide dropdown-reduced-height gl-min-h-7!" + :container-class="tokenSelectorContainerClass" + data-testid="work-item-token-select-input" + @text-input="debouncedSearchKeyUpdate" + @focus="handleFocus" + @mouseover.native="handleMouseOver" + @mouseout.native="handleMouseOut" + > + <template #token-content="{ token }"> + {{ token.title }} + </template> + <template #dropdown-item-content="{ dropdownItem }"> + <div class="gl-display-flex"> + <div class="gl-text-secondary gl-mr-4">{{ getIdFromGraphQLId(dropdownItem.id) }}</div> + <div class="gl-text-truncate">{{ dropdownItem.title }}</div> + </div> + </template> + </gl-token-selector> + <div + v-if="showWorkItemsToAddInvalidMessage" + class="gl-text-red-500" + data-testid="work-items-invalid" + > + {{ workItemsToAddInvalidMessage }} + </div> + </div> <gl-button category="primary" variant="confirm" diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue index f06de2ca048..81e2bb76900 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue @@ -40,10 +40,20 @@ export default { type: String, required: true, }, + parentWorkItemType: { + type: String, + required: false, + default: '', + }, workItemId: { type: String, required: true, }, + confidential: { + type: Boolean, + required: false, + default: false, + }, children: { type: Array, required: false, @@ -221,8 +231,10 @@ export default { data-testid="add-tree-form" :issuable-gid="workItemId" :form-type="formType" + :parent-work-item-type="parentWorkItemType" :children-type="childType" :children-ids="childrenIds" + :parent-confidential="confidential" @addWorkItemChild="$emit('addWorkItemChild', $event)" @cancel="hideAddForm" /> @@ -233,11 +245,13 @@ export default { :can-update="canUpdate" :issuable-gid="workItemId" :child-item="child" + :confidential="child.confidential" :work-item-type="workItemType" :has-indirect-children="hasIndirectChildren" @mouseover="prefetchWorkItem(child)" @mouseout="clearPrefetching" @removeChild="$emit('removeChild', $event)" + @click="$emit('show-modal', $event, $event.childItem || child)" /> </div> </div> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue index 911cac4de88..71de6867680 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue @@ -63,6 +63,7 @@ export default { :child-item="child" :work-item-type="workItemType" @removeChild="updateWorkItem" + @click="$emit('click', Object.assign($event, { childItem: child }))" /> </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_note_signed_out.vue b/app/assets/javascripts/work_items/components/work_item_note_signed_out.vue new file mode 100644 index 00000000000..3ef4a16bc57 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_note_signed_out.vue @@ -0,0 +1,31 @@ +<script> +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { __, sprintf } from '~/locale'; + +export default { + directives: { + SafeHtml, + }, + inject: ['registerPath', 'signInPath'], + computed: { + signedOutText() { + return sprintf( + __( + 'Please %{startTagRegister}register%{endRegisterTag} or %{startTagSignIn}sign in%{endSignInTag} to reply', + ), + { + startTagRegister: `<a href="${this.registerPath}">`, + startTagSignIn: `<a href="${this.signInPath}">`, + endRegisterTag: '</a>', + endSignInTag: '</a>', + }, + false, + ); + }, + }, +}; +</script> + +<template> + <div v-safe-html="signedOutText" class="disabled-comment gl-text-center"></div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue index 91e90589a93..a59767d8b70 100644 --- a/app/assets/javascripts/work_items/components/work_item_notes.vue +++ b/app/assets/javascripts/work_items/components/work_item_notes.vue @@ -2,8 +2,12 @@ import { GlSkeletonLoader } from '@gitlab/ui'; import { s__ } from '~/locale'; import SystemNote from '~/work_items/components/notes/system_note.vue'; +import ActivityFilter from '~/work_items/components/notes/activity_filter.vue'; import { i18n, DEFAULT_PAGE_SIZE_NOTES } from '~/work_items/constants'; +import { ASC, DESC } from '~/notes/constants'; import { getWorkItemNotesQuery } from '~/work_items/utils'; +import WorkItemNote from '~/work_items/components/notes/work_item_note.vue'; +import WorkItemCommentForm from './work_item_comment_form.vue'; export default { i18n: { @@ -15,8 +19,11 @@ export default { height: 40, }, components: { - SystemNote, GlSkeletonLoader, + ActivityFilter, + SystemNote, + WorkItemCommentForm, + WorkItemNote, }, props: { workItemId: { @@ -31,22 +38,50 @@ export default { type: String, required: true, }, + workItemType: { + type: String, + required: true, + }, fetchByIid: { type: Boolean, required: false, default: false, }, }, + data() { + return { + notesArray: [], + isLoadingMore: false, + perPage: DEFAULT_PAGE_SIZE_NOTES, + sortOrder: ASC, + changeNotesSortOrderAfterLoading: false, + }; + }, computed: { - areNotesLoading() { - return this.$apollo.queries.workItemNotes.loading; - }, - notes() { - return this.workItemNotes?.nodes; + initialLoading() { + return this.$apollo.queries.workItemNotes.loading && !this.isLoadingMore; }, pageInfo() { return this.workItemNotes?.pageInfo; }, + avatarUrl() { + return window.gon.current_user_avatar_url; + }, + hasNextPage() { + return this.pageInfo?.hasNextPage; + }, + showInitialLoader() { + return this.initialLoading || this.changeNotesSortOrderAfterLoading; + }, + showTimeline() { + return !this.changeNotesSortOrderAfterLoading; + }, + showLoadingMoreSkeleton() { + return this.isLoadingMore && !this.changeNotesSortOrderAfterLoading; + }, + disableActivityFilter() { + return this.initialLoading || this.isLoadingMore; + }, }, apollo: { workItemNotes: { @@ -59,6 +94,7 @@ export default { variables() { return { ...this.queryVariables, + after: this.after, pageSize: DEFAULT_PAGE_SIZE_NOTES, }; }, @@ -66,7 +102,11 @@ export default { const workItemWidgets = this.fetchByIid ? data.workspace?.workItems?.nodes[0]?.widgets : data.workItem?.widgets; - return workItemWidgets.find((widget) => widget.type === 'NOTES').discussions || []; + const discussionNodes = + workItemWidgets.find((widget) => widget.type === 'NOTES')?.discussions || []; + this.notesArray = discussionNodes?.nodes || []; + this.updateSortingOrderIfApplicable(); + return discussionNodes; }, skip() { return !this.queryVariables.id && !this.queryVariables.iid; @@ -74,6 +114,58 @@ export default { error() { this.$emit('error', i18n.fetchError); }, + result() { + if (this.hasNextPage) { + this.fetchMoreNotes(); + } + }, + }, + }, + methods: { + isSystemNote(note) { + return note.notes.nodes[0].system; + }, + updateSortingOrderIfApplicable() { + // when the sort order is DESC in local storage and there is only a single page, call + // changeSortOrder manually + if ( + this.changeNotesSortOrderAfterLoading && + this.perPage === DEFAULT_PAGE_SIZE_NOTES && + !this.hasNextPage + ) { + this.changeNotesSortOrder(DESC); + } + }, + updateInitialSortedOrder(direction) { + this.sortOrder = direction; + // when the direction is reverse , we need to load all since the sorting is on the frontend + if (direction === DESC) { + this.changeNotesSortOrderAfterLoading = true; + } + }, + changeNotesSortOrder(direction) { + this.sortOrder = direction; + this.notesArray = [...this.notesArray].reverse(); + this.changeNotesSortOrderAfterLoading = false; + }, + async fetchMoreNotes() { + this.isLoadingMore = true; + // copied from discussions batch logic - every fetchMore call has a higher + // amount of page size than the previous one with the limit being 100 + this.perPage = Math.min(Math.round(this.perPage * 1.5), 100); + await this.$apollo.queries.workItemNotes + .fetchMore({ + variables: { + ...this.queryVariables, + pageSize: this.perPage, + after: this.pageInfo?.endCursor, + }, + }) + .catch((error) => this.$emit('error', error.message)); + this.isLoadingMore = false; + if (this.changeNotesSortOrderAfterLoading && !this.hasNextPage) { + this.changeNotesSortOrder(this.sortOrder); + } }, }, }; @@ -81,8 +173,18 @@ export default { <template> <div class="gl-border-t gl-mt-5"> - <label class="gl-mb-0">{{ $options.i18n.ACTIVITY_LABEL }}</label> - <div v-if="areNotesLoading" class="gl-mt-5"> + <div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap"> + <label class="gl-mb-0">{{ $options.i18n.ACTIVITY_LABEL }}</label> + <activity-filter + class="gl-min-h-5 gl-pb-3" + :loading="disableActivityFilter" + :sort-order="sortOrder" + :work-item-type="workItemType" + @changeSortOrder="changeNotesSortOrder" + @updateSavedSortOrder="updateInitialSortedOrder" + /> + </div> + <div v-if="showInitialLoader" class="gl-mt-5"> <gl-skeleton-loader v-for="index in $options.loader.repeat" :key="index" @@ -94,16 +196,40 @@ export default { <rect width="500" x="45" y="15" height="10" rx="4" /> </gl-skeleton-loader> </div> - <div v-else class="issuable-discussion gl-mb-5 work-item-notes"> - <template v-if="notes && notes.length"> - <ul class="notes main-notes-list timeline"> - <system-note - v-for="note in notes" - :key="note.notes.nodes[0].id" - :note="note.notes.nodes[0]" + <div v-else class="issuable-discussion gl-mb-5 gl-clearfix!"> + <template v-if="showTimeline"> + <ul class="notes main-notes-list timeline gl-clearfix!"> + <template v-for="note in notesArray"> + <system-note + v-if="isSystemNote(note)" + :key="note.notes.nodes[0].id" + :note="note.notes.nodes[0]" + /> + <work-item-note v-else :key="note.notes.nodes[0].id" :note="note.notes.nodes[0]" /> + </template> + + <work-item-comment-form + :query-variables="queryVariables" + :full-path="fullPath" + :work-item-id="workItemId" + :fetch-by-iid="fetchByIid" + @error="$emit('error', $event)" /> </ul> </template> + + <template v-if="showLoadingMoreSkeleton"> + <gl-skeleton-loader + v-for="index in $options.loader.repeat" + :key="index" + :width="$options.loader.width" + :height="$options.loader.height" + preserve-aspect-ratio="xMinYMax meet" + > + <circle cx="20" cy="20" r="16" /> + <rect width="500" x="45" y="15" height="10" rx="4" /> + </gl-skeleton-loader> + </template> </div> </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_type_icon.vue b/app/assets/javascripts/work_items/components/work_item_type_icon.vue index 32678e29fa4..96a6493357c 100644 --- a/app/assets/javascripts/work_items/components/work_item_type_icon.vue +++ b/app/assets/javascripts/work_items/components/work_item_type_icon.vue @@ -33,11 +33,6 @@ export default { }, computed: { iconName() { - // TODO: Remove this once https://gitlab.com/gitlab-org/gitlab-svgs/-/merge_requests/865 - // is merged and updated in GitLab repo. - if (this.workItemIconName === 'issue-type-keyresult') { - return 'issue-type-key-result'; - } return ( this.workItemIconName || WORK_ITEMS_TYPE_MAP[this.workItemType]?.icon || 'issue-type-issue' ); diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 3cd17f4d360..81f9bf04bc8 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -31,7 +31,12 @@ export const WORK_ITEM_TYPE_ENUM_REQUIREMENTS = 'REQUIREMENTS'; export const WORK_ITEM_TYPE_ENUM_OBJECTIVE = 'OBJECTIVE'; export const WORK_ITEM_TYPE_ENUM_KEY_RESULT = 'KEY_RESULT'; +export const WORK_ITEM_TYPE_VALUE_INCIDENT = 'Incident'; export const WORK_ITEM_TYPE_VALUE_ISSUE = 'Issue'; +export const WORK_ITEM_TYPE_VALUE_TASK = 'Task'; +export const WORK_ITEM_TYPE_VALUE_TEST_CASE = 'Test case'; +export const WORK_ITEM_TYPE_VALUE_REQUIREMENTS = 'Requirements'; +export const WORK_ITEM_TYPE_VALUE_KEY_RESULT = 'Key Result'; export const WORK_ITEM_TYPE_VALUE_OBJECTIVE = 'Objective'; export const i18n = { @@ -41,7 +46,7 @@ export const i18n = { ), updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'), confidentialTooltip: s__( - 'WorkItem|Only project members with at least the Reporter role, the author, and assignees can view or be notified about this task.', + 'WorkItem|Only project members with at least the Reporter role, the author, and assignees can view or be notified about this %{workItemType}.', ), }; @@ -73,12 +78,19 @@ export const I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL = s__('WorkItem|Add %{work export const I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER = s__( 'WorkItem|Search existing %{workItemType}s', ); +export const I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL = s__( + 'WorkItem|This %{workItemType} is confidential and should only be visible to team members with at least Reporter access', +); +export const I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_TOOLTIP = s__( + 'WorkItem|A non-confidential %{workItemType} cannot be assigned to a confidential parent %{parentWorkItemType}.', +); -export const sprintfWorkItem = (msg, workItemTypeArg) => { +export const sprintfWorkItem = (msg, workItemTypeArg, parentWorkItemType = '') => { const workItemType = workItemTypeArg || s__('WorkItem|Work item'); return capitalizeFirstCharacter( sprintf(msg, { workItemType: workItemType.toLocaleLowerCase(), + parentWorkItemType: parentWorkItemType.toLocaleLowerCase(), }), ); }; @@ -96,30 +108,37 @@ export const WORK_ITEMS_TYPE_MAP = { [WORK_ITEM_TYPE_ENUM_INCIDENT]: { icon: `issue-type-incident`, name: s__('WorkItem|Incident'), + value: WORK_ITEM_TYPE_VALUE_INCIDENT, }, [WORK_ITEM_TYPE_ENUM_ISSUE]: { icon: `issue-type-issue`, name: s__('WorkItem|Issue'), + value: WORK_ITEM_TYPE_VALUE_ISSUE, }, [WORK_ITEM_TYPE_ENUM_TASK]: { icon: `issue-type-task`, name: s__('WorkItem|Task'), + value: WORK_ITEM_TYPE_VALUE_TASK, }, [WORK_ITEM_TYPE_ENUM_TEST_CASE]: { icon: `issue-type-test-case`, name: s__('WorkItem|Test case'), + value: WORK_ITEM_TYPE_VALUE_TEST_CASE, }, [WORK_ITEM_TYPE_ENUM_REQUIREMENTS]: { icon: `issue-type-requirements`, name: s__('WorkItem|Requirements'), + value: WORK_ITEM_TYPE_VALUE_REQUIREMENTS, }, [WORK_ITEM_TYPE_ENUM_OBJECTIVE]: { icon: `issue-type-objective`, name: s__('WorkItem|Objective'), + value: WORK_ITEM_TYPE_VALUE_OBJECTIVE, }, [WORK_ITEM_TYPE_ENUM_KEY_RESULT]: { - icon: `issue-type-issue`, + icon: `issue-type-keyresult`, name: s__('WorkItem|Key Result'), + value: WORK_ITEM_TYPE_VALUE_KEY_RESULT, }, }; @@ -141,7 +160,7 @@ export const WORK_ITEM_NAME_TO_ICON_MAP = { Task: 'issue-type-task', Objective: 'issue-type-objective', // eslint-disable-next-line @gitlab/require-i18n-strings - 'Key Result': 'issue-type-key-result', + 'Key Result': 'issue-type-keyresult', }; export const FORM_TYPES = { @@ -154,4 +173,6 @@ export const FORM_TYPES = { }; export const DEFAULT_PAGE_SIZE_ASSIGNEES = 10; -export const DEFAULT_PAGE_SIZE_NOTES = 100; +export const DEFAULT_PAGE_SIZE_NOTES = 30; + +export const WORK_ITEM_NOTES_SORT_ORDER_KEY = 'sort_direction_work_item'; diff --git a/app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql new file mode 100644 index 00000000000..6a7afd7bd5b --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql @@ -0,0 +1,5 @@ +mutation createWorkItemNote($input: CreateNoteInput!) { + createNote(input: $input) { + errors + } +} diff --git a/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql index 3a23db3886a..fce10f6f2a6 100644 --- a/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql +++ b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql @@ -11,6 +11,7 @@ query projectWorkItems( id title state + confidential } } } diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql index 6a81cc230b1..3ee263c149d 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -12,6 +12,7 @@ fragment WorkItem on WorkItem { project { id fullPath + archived } workItemType { id diff --git a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql index 7fcf622cdb2..7d7bb9c7fc5 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql @@ -3,6 +3,7 @@ query workItemLinksQuery($id: WorkItemID!) { id workItemType { id + name } title userPermissions { diff --git a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql index baefcdaea93..b7813ca4dc6 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql @@ -19,7 +19,6 @@ fragment WorkItemMetadataWidgets on WorkItemWidget { } ... on WorkItemWidgetLabels { type - allowsScopedLabels labels { nodes { ...Label diff --git a/app/assets/javascripts/work_items/graphql/discussion.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_note.fragment.graphql index 62ced6bdfea..5215ea10918 100644 --- a/app/assets/javascripts/work_items/graphql/discussion.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_note.fragment.graphql @@ -1,12 +1,16 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" -fragment Discussion on Note { +fragment WorkItemNote on Note { id - body bodyHtml + system + internal systemNoteIconName createdAt author { ...User } + userPermissions { + adminNote + } } diff --git a/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql index 9439f22f955..9ea9cecc81a 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql @@ -1,5 +1,5 @@ #import "~/graphql_shared/fragments/page_info.fragment.graphql" -#import "~/work_items/graphql/discussion.fragment.graphql" +#import "~/work_items/graphql/work_item_note.fragment.graphql" query workItemNotes($id: WorkItemID!, $after: String, $pageSize: Int) { workItem(id: $id) { @@ -8,7 +8,7 @@ query workItemNotes($id: WorkItemID!, $after: String, $pageSize: Int) { widgets { ... on WorkItemWidgetNotes { type - discussions(first: $pageSize, after: $after, filter: ONLY_ACTIVITY) { + discussions(first: $pageSize, after: $after, filter: ALL_NOTES) { pageInfo { ...PageInfo } @@ -16,7 +16,7 @@ query workItemNotes($id: WorkItemID!, $after: String, $pageSize: Int) { id notes { nodes { - ...Discussion + ...WorkItemNote } } } diff --git a/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql index 3e0960f3f54..f401aa5595e 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql @@ -1,5 +1,5 @@ #import "~/graphql_shared/fragments/page_info.fragment.graphql" -#import "~/work_items/graphql/discussion.fragment.graphql" +#import "~/work_items/graphql/work_item_note.fragment.graphql" query workItemNotesByIid($fullPath: ID!, $iid: String, $after: String, $pageSize: Int) { workspace: project(fullPath: $fullPath) { @@ -11,7 +11,7 @@ query workItemNotesByIid($fullPath: ID!, $iid: String, $after: String, $pageSize widgets { ... on WorkItemWidgetNotes { type - discussions(first: $pageSize, after: $after, filter: ONLY_ACTIVITY) { + discussions(first: $pageSize, after: $after, filter: ALL_NOTES) { pageInfo { ...PageInfo } @@ -19,7 +19,7 @@ query workItemNotesByIid($fullPath: ID!, $iid: String, $after: String, $pageSize id notes { nodes { - ...Discussion + ...WorkItemNote } } } diff --git a/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql index 006ca29e01c..b4fb83b24c2 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql @@ -1,6 +1,6 @@ #import "~/graphql_shared/fragments/label.fragment.graphql" #import "~/graphql_shared/fragments/user.fragment.graphql" -#import "./work_item_metadata_widgets.fragment.graphql" +#import "ee_else_ce/work_items/graphql/work_item_metadata_widgets.fragment.graphql" query workItemTreeQuery($id: WorkItemID!) { workItem(id: $id) { diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql index cf3374e1737..d2a2d7927d3 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -1,7 +1,7 @@ #import "~/graphql_shared/fragments/label.fragment.graphql" #import "~/graphql_shared/fragments/user.fragment.graphql" #import "~/work_items/graphql/milestone.fragment.graphql" -#import "./work_item_metadata_widgets.fragment.graphql" +#import "ee_else_ce/work_items/graphql/work_item_metadata_widgets.fragment.graphql" fragment WorkItemWidgets on WorkItemWidget { ... on WorkItemWidgetDescription { diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index a056fde6928..98b59449af7 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -10,6 +10,8 @@ export const initWorkItemsRoot = () => { fullPath, hasIssueWeightsFeature, issuesListPath, + registerPath, + signInPath, hasIterationsFeature, hasOkrsFeature, hasIssuableHealthStatusFeature, @@ -26,6 +28,8 @@ export const initWorkItemsRoot = () => { hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), hasOkrsFeature: parseBoolean(hasOkrsFeature), issuesListPath, + registerPath, + signInPath, hasIterationsFeature: parseBoolean(hasIterationsFeature), hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), }, diff --git a/app/assets/stylesheets/fonts.scss b/app/assets/stylesheets/fonts.scss index a6ecca88bd4..a023b41083d 100644 --- a/app/assets/stylesheets/fonts.scss +++ b/app/assets/stylesheets/fonts.scss @@ -26,7 +26,36 @@ Usage: src: font-url('jetbrains-mono/JetBrainsMono.woff2') format('woff2'); } +@font-face { + font-family: 'JetBrains Mono'; + font-display: optional; + font-weight: bold; + src: font-url('jetbrains-mono/JetBrainsMono-Bold.woff2') format('woff2'); +} + +@font-face { + font-family: 'JetBrains Mono'; + font-display: optional; + font-weight: normal; + font-style: italic; + src: font-url('jetbrains-mono/JetBrainsMono-Italic.woff2') format('woff2'); +} + +@font-face { + font-family: 'JetBrains Mono'; + font-display: optional; + font-weight: bold; + font-style: italic; + src: font-url('jetbrains-mono/JetBrainsMono-BoldItalic.woff2') format('woff2'); +} + :root { --default-mono-font: 'JetBrains Mono', 'Menlo'; --default-regular-font: 'GitLab Sans', -apple-system; } + +// This isn't the best solution, but we needed a quick fix +// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/107592/ +* { + font-variant-ligatures: none; +} diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 07db6b3c147..e60353578b0 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -38,6 +38,7 @@ @import 'framework/sidebar'; @import 'framework/contextual_sidebar_header'; @import 'framework/contextual_sidebar'; +@import 'framework/super_sidebar'; @import 'framework/tables'; @import 'framework/notes'; @import 'framework/tabs'; diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 14e756a5c21..0bc920b1f73 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -471,6 +471,10 @@ img.emoji { .gl-font-size-28 { font-size: $gl-font-size-28; } .gl-font-size-42 { font-size: $gl-font-size-42; } +.gl-icon-button:hover { + background-color: $gray-100; +} + .border-section { @include gl-py-6; @include gl-m-0; diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index 34c7ffa58fe..1e05441c731 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -138,13 +138,12 @@ } @mixin top-level-item { + @include gl-h-7; @include gl-px-4; - @include gl-py-3; @include gl-display-flex; @include gl-align-items-center; @include gl-rounded-base; @include gl-w-auto; - @include gl-line-height-normal; transition: none; margin: $sidebar-top-item-tb-margin $sidebar-top-item-lr-margin; @@ -339,6 +338,7 @@ a { @include top-level-item; @include context-header; + @include gl-h-auto; } } } @@ -348,6 +348,7 @@ .context-header a { @include context-header; + @include gl-h-auto; } > li { @@ -457,9 +458,9 @@ // PANELS-SPECIFIC // +.icon-avatar, .settings-avatar { svg { margin: auto; } } - diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 0acda85f527..65d7eafb8b8 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -851,10 +851,6 @@ @include gl-focus($inset: true); } } - - .frequent-items-list-item-container a { - display: flex; - } } .section-header { @@ -873,9 +869,10 @@ .frequent-items-item-title, .frequent-items-item-namespace { - max-width: 250px; + max-width: 220px; text-overflow: ellipsis; white-space: nowrap; + overflow: hidden; } .frequent-items-item-title { @@ -895,6 +892,11 @@ .frequent-items-item-metadata-container { float: none; } + + .frequent-items-item-title, + .frequent-items-item-namespace { + max-width: 250px; + } } } diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index ea741af918c..98083fbc72a 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -37,3 +37,14 @@ } } } + +.approvers-select { + .dropdown-menu { + @include gl-w-full; + @include gl-max-w-none; + } + + .gl-dropdown-item-check-icon { + @include gl-display-none; + } +} diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss new file mode 100644 index 00000000000..59a9df9ede0 --- /dev/null +++ b/app/assets/stylesheets/framework/super_sidebar.scss @@ -0,0 +1,22 @@ +.super-sidebar { + top: 0; + width: $contextual-sidebar-width; + + .user-bar { + background-color: $t-gray-a-04; + + .tanuki-logo { + @include gl-vertical-align-middle; + } + } + + .context-switcher-toggle { + &[aria-expanded='true'] { + background-color: $t-gray-a-08; + } + } +} + +.with-performance-bar .super-sidebar { + top: $performance-bar-height; +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index ec8ffaf8c53..539e92eeca4 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -392,7 +392,7 @@ $gl-font-size-large: 16px; $gl-font-weight-normal: 400; $gl-font-weight-bold: 600; $gl-text-color: $gray-900; -$gl-text-color-secondary: $gray-500; +$gl-text-color-secondary: $gray-500 !default; $gl-text-color-tertiary: $gray-400; $gl-text-color-quaternary: #d6d6d6; $gl-text-color-inverted: $white; diff --git a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss index 2c5ea8347ae..e3cec187fab 100644 --- a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss +++ b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss @@ -339,19 +339,3 @@ display: inline; } } - -.gl-select2-html5-required-fix { - .select2-container { - + .select2 { - @include gl-opacity-0; - @include gl-border-0; - @include gl-bg-none; - @include gl-bg-transparent; - display: block !important; - width: 1px; - height: 1px; - z-index: -1; - margin: -3px auto 0; - } - } -} diff --git a/app/assets/stylesheets/page_bundles/ci_status.scss b/app/assets/stylesheets/page_bundles/ci_status.scss index 6b976106cc9..7adbf10b83a 100644 --- a/app/assets/stylesheets/page_bundles/ci_status.scss +++ b/app/assets/stylesheets/page_bundles/ci_status.scss @@ -1,10 +1,7 @@ @import 'mixins_and_variables_and_functions'; .ci-status { - padding: 2px 7px 4px; border: 1px solid var(--border-color, $border-color); - white-space: nowrap; - border-radius: 4px; &:hover, &:focus { diff --git a/app/assets/stylesheets/page_bundles/editor.scss b/app/assets/stylesheets/page_bundles/editor.scss index b7b698b2128..36da979ba1f 100644 --- a/app/assets/stylesheets/page_bundles/editor.scss +++ b/app/assets/stylesheets/page_bundles/editor.scss @@ -163,7 +163,6 @@ .gitignore-selector, .gitlab-ci-yml-selector, .dockerfile-selector, - .template-type-selector, .metrics-dashboard-selector { display: inline-block; vertical-align: top; diff --git a/app/assets/stylesheets/page_bundles/import.scss b/app/assets/stylesheets/page_bundles/import.scss index cd5e6d32e4e..a6c08e344f9 100644 --- a/app/assets/stylesheets/page_bundles/import.scss +++ b/app/assets/stylesheets/page_bundles/import.scss @@ -1,48 +1,10 @@ @import 'mixins_and_variables_and_functions'; -.import-jobs-from-col { - width: 37%; -} - - -.import-jobs-to-col { - width: 37%; -} - -.import-jobs-status-col { - width: 25%; -} - -.import-jobs-cta-col { - width: 1%; -} - -.import-entities-target-select { - &.disabled { - .import-entities-target-select-separator { - color: var(--gray-400, $gray-400); - border-color: var(--gray-100, $gray-100); - background-color: var(--gray-10, $gray-10); - } - } - - .import-entities-target-select-separator { - border-color: var(--gray-200, $gray-200); - background-color: var(--gray-10, $gray-10); - } - - .gl-form-input { - box-shadow: inset 0 0 0 1px var(--gray-200, $gray-200); - } -} - $import-bar-height: $gl-spacing-scale-11; .import-table-bar { - @include gl-sticky; height: $import-bar-height; top: $header-height; - z-index: 3; html.with-performance-bar & { top: calc(#{$header-height} + #{$performance-bar-height}); @@ -50,16 +12,11 @@ $import-bar-height: $gl-spacing-scale-11; } .import-table { - border-collapse: separate; - thead { - @include gl-sticky; - background-color: var(--gray-10, $gray-10); top: calc(#{$header-height} + #{$import-bar-height}); - z-index: 3; html.with-performance-bar & { - top: calc(#{$header-height + $performance-bar-height} + #{$import-bar-height}); + top: calc(#{$header-height} + #{$performance-bar-height} + #{$import-bar-height}); } } } diff --git a/app/assets/stylesheets/page_bundles/members.scss b/app/assets/stylesheets/page_bundles/members.scss index 8d2c0a8ca22..826921be8f0 100644 --- a/app/assets/stylesheets/page_bundles/members.scss +++ b/app/assets/stylesheets/page_bundles/members.scss @@ -76,6 +76,10 @@ width: px-to-rem(200px); } + .col-activity { + width: px-to-rem(250px); + } + .col-actions { width: px-to-rem(65px); } diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index 4950561bcb7..5c699dd81df 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -1002,12 +1002,12 @@ $tabs-holder-z-index: 250; .md-header { .gl-tab-nav-item { - @include gl-text-gray-900; + color: var(--gl-text-color, $gl-text-color); @include gl-pb-5; &:hover { @include gl-bg-none; - @include gl-text-gray-900; + color: var(--gl-text-color, $gl-text-color); &:not(.gl-tab-nav-item-active) { @include gl-inset-border-b-2-gray-200; @@ -1017,7 +1017,7 @@ $tabs-holder-z-index: 250; .gl-tab-nav-item-active { @include gl-font-weight-bold; - @include gl-text-gray-900; + color: var(--gl-text-color, $gl-text-color); @include gl-inset-border-b-2-theme-accent; &:active, @@ -1197,13 +1197,13 @@ $tabs-holder-z-index: 250; } .mr-section-container { + .media-body { + column-gap: 0; + } + .state-container-action-buttons { @include media-breakpoint-up(md) { flex-direction: row-reverse; - - .btn { - margin-left: auto; - } } } } diff --git a/app/assets/stylesheets/page_bundles/oncall_schedules.scss b/app/assets/stylesheets/page_bundles/oncall_schedules.scss index b995724ec7c..f08d6e3ca95 100644 --- a/app/assets/stylesheets/page_bundles/oncall_schedules.scss +++ b/app/assets/stylesheets/page_bundles/oncall_schedules.scss @@ -5,14 +5,14 @@ } .timezone-dropdown { - .dropdown-menu { - @include gl-w-full; - } - .gl-dropdown-item-text-primary { @include gl-overflow-hidden; @include gl-text-overflow-ellipsis; } + + .btn-block { + margin-bottom: 0; + } } .modal-footer { @@ -20,7 +20,7 @@ } .invalid-dropdown { - .gl-dropdown-toggle { + .gl-button.gl-dropdown-toggle { @include inset-border-1-red-500; &:hover { diff --git a/app/assets/stylesheets/page_bundles/todos.scss b/app/assets/stylesheets/page_bundles/todos.scss index b35f5b38740..d4b0b4169d3 100644 --- a/app/assets/stylesheets/page_bundles/todos.scss +++ b/app/assets/stylesheets/page_bundles/todos.scss @@ -79,11 +79,11 @@ @include gl-py-0; @include gl-px-1; @include gl-m-0; - @include gl-bg-gray-50; @include gl-border-0; @include gl-rounded-base; @include gl-display-inline-flex; - @include gl-text-body; + background: var(--gray-50, $gray-50); + color: var(--gl-text-color, $gl-text-color); } .gl-label-scoped { diff --git a/app/assets/stylesheets/pages/ml_experiment_tracking.scss b/app/assets/stylesheets/pages/ml_experiment_tracking.scss index c1582f2090b..3c025b5d23f 100644 --- a/app/assets/stylesheets/pages/ml_experiment_tracking.scss +++ b/app/assets/stylesheets/pages/ml_experiment_tracking.scss @@ -15,6 +15,20 @@ } } +table.ml-candidate-table { + table-layout: fixed; + + tr td, + tr th { + padding: $gl-padding-8; + + > * { + @include gl-display-block; + @include gl-text-truncate; + } + } +} + table.candidate-details { td { padding: $gl-spacing-scale-3; diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 89be1c024db..d26e29c4047 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -63,18 +63,17 @@ } } + @include media-breakpoint-down(md) { + .time-ago { + align-items: flex-end; + } + } + .duration, .finished-at { color: $gl-text-color-secondary; margin: 0; white-space: nowrap; - - svg { - width: 12px; - height: 12px; - vertical-align: middle; - margin-right: 4px; - } } .build-link a { diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 15a32ea8ad3..ee91d955019 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -122,7 +122,7 @@ padding: 0; background: transparent; border: 0; - line-height: 34px; + line-height: $gl-line-height-32; margin: 0; a { @@ -495,7 +495,7 @@ .protected-branches-list, .protected-tags-list { - margin-bottom: 30px; + margin-bottom: 32px; .settings-message { margin: 0; diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index c7e55289b11..bb83a91bc57 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -536,10 +536,11 @@ a.gl-badge.badge-warning:active { color: #89888d; } .gl-search-box-by-type-search-icon { - margin: 0.5rem; color: #89888d; width: 1rem; position: absolute; + left: 0.5rem; + top: calc(50% - 16px / 2); } .gl-search-box-by-type { display: flex; @@ -591,7 +592,7 @@ svg { } .toggle-sidebar-button .collapse-text, .toggle-sidebar-button .icon-chevron-double-lg-left { - color: #89888d; + color: #bfbfc3; } html { overflow-y: scroll; @@ -1136,15 +1137,13 @@ kbd { } .nav-sidebar li > a, .nav-sidebar li > .fly-out-top-item-container { + height: 2rem; padding-left: 0.75rem; padding-right: 0.75rem; - padding-top: 0.5rem; - padding-bottom: 0.5rem; display: flex; align-items: center; border-radius: 0.25rem; width: auto; - line-height: 1rem; margin: 1px 8px; } .nav-sidebar li.active > a { @@ -1373,19 +1372,18 @@ kbd { margin-top: 0.25rem; } .nav-sidebar-inner-scroll > div.context-header a { + height: 2rem; padding-left: 0.75rem; padding-right: 0.75rem; - padding-top: 0.5rem; - padding-bottom: 0.5rem; display: flex; align-items: center; border-radius: 0.25rem; width: auto; - line-height: 1rem; margin: 1px 8px; padding: 0.25rem; margin-bottom: 0.25rem; margin-top: 0.125rem; + height: auto; } .nav-sidebar-inner-scroll > div.context-header a .avatar-container { font-weight: 400; @@ -1398,6 +1396,7 @@ kbd { padding: 0.25rem; margin-bottom: 0.25rem; margin-top: 0.125rem; + height: auto; } .sidebar-top-level-items .context-header a .avatar-container { font-weight: 400; @@ -1428,7 +1427,7 @@ kbd { padding: 0 16px; background-color: #24232a; border: 0; - color: #89888d; + color: #bfbfc3; display: flex; align-items: center; background-color: #1f1e24; diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss index f24b6fb9e81..9e1c6b065a0 100644 --- a/app/assets/stylesheets/startup/startup-general.scss +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -536,10 +536,11 @@ a.gl-badge.badge-warning:active { color: #737278; } .gl-search-box-by-type-search-icon { - margin: 0.5rem; color: #737278; width: 1rem; position: absolute; + left: 0.5rem; + top: calc(50% - 16px / 2); } .gl-search-box-by-type { display: flex; @@ -1136,15 +1137,13 @@ kbd { } .nav-sidebar li > a, .nav-sidebar li > .fly-out-top-item-container { + height: 2rem; padding-left: 0.75rem; padding-right: 0.75rem; - padding-top: 0.5rem; - padding-bottom: 0.5rem; display: flex; align-items: center; border-radius: 0.25rem; width: auto; - line-height: 1rem; margin: 1px 8px; } .nav-sidebar li.active > a { @@ -1373,19 +1372,18 @@ kbd { margin-top: 0.25rem; } .nav-sidebar-inner-scroll > div.context-header a { + height: 2rem; padding-left: 0.75rem; padding-right: 0.75rem; - padding-top: 0.5rem; - padding-bottom: 0.5rem; display: flex; align-items: center; border-radius: 0.25rem; width: auto; - line-height: 1rem; margin: 1px 8px; padding: 0.25rem; margin-bottom: 0.25rem; margin-top: 0.125rem; + height: auto; } .nav-sidebar-inner-scroll > div.context-header a .avatar-container { font-weight: 400; @@ -1398,6 +1396,7 @@ kbd { padding: 0.25rem; margin-bottom: 0.25rem; margin-top: 0.125rem; + height: auto; } .sidebar-top-level-items .context-header a .avatar-container { font-weight: 400; diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss index 8db91fd9908..c471d6183d8 100644 --- a/app/assets/stylesheets/themes/_dark.scss +++ b/app/assets/stylesheets/themes/_dark.scss @@ -115,6 +115,8 @@ $data-viz-blue-950: #e9ebff; $border-white-normal: $border-color; +$gl-text-color-secondary: $gray-700; + $body-bg: $gray-10; $input-bg: $white; $input-focus-bg: $white; @@ -130,7 +132,7 @@ $popover-color: $gray-950; $popover-box-shadow: 0 2px 3px 1px $gray-700; $popover-arrow-outer-color: $gray-800; -$secondary: $gray-600; +$secondary: $gray-700; $yiq-text-dark: $gray-50; $yiq-text-light: $gray-950; diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 714dd932147..7d98a780e55 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -236,13 +236,44 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709 } } -// TODO: Remove once https: //gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/3198 is merged -.gl-sm-ml-5 { - @include gl-media-breakpoint-up(sm) { - @include gl-ml-5; +.gl-mt-n5 { + margin-top: -$gl-spacing-scale-5; +} + +// Utils below are very specific so cannot be part of GitLab UI +.gl-md-mt-5 { + @include gl-media-breakpoint-up(md) { + margin-top: $gl-spacing-scale-5; + } +} + +.gl-sm-mr-0\! { + @include gl-media-breakpoint-down(md) { + margin-right: 0 !important; + } +} + +.gl-sm-mb-5 { + @include gl-media-breakpoint-down(md) { + margin-bottom: $gl-spacing-scale-5; + } +} + +.gl-md-mb-3\! { + @include gl-media-breakpoint-up(md) { + margin-bottom: $gl-spacing-scale-3 !important; } } + +.gl-gap-2 { + gap: $gl-spacing-scale-2; +} + +.gl-hover-bg-t-gray-a-08:hover { + background-color: $t-gray-a-08; +} + /* End gitlab-ui#1709 */ /* @@ -263,3 +294,7 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709 .gl-max-w-0 { max-width: 0; } + +.gl-isolate { + isolation: isolate; +} |