diff options
Diffstat (limited to 'app/assets/javascripts')
376 files changed, 5506 insertions, 2870 deletions
diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue index 567d7151847..f5d21ece138 100644 --- a/app/assets/javascripts/admin/users/components/user_actions.vue +++ b/app/assets/javascripts/admin/users/components/user_actions.vue @@ -109,12 +109,13 @@ export default { <div v-if="hasDropdownActions" class="gl-p-2"> <gl-dropdown data-testid="dropdown-toggle" - right :text="$options.i18n.userAdministration" :text-sr-only="!showButtonLabels" icon="ellipsis_h" data-qa-selector="user_actions_dropdown_toggle" :data-qa-username="user.username" + no-caret + right > <gl-dropdown-section-header>{{ $options.i18n.userAdministration diff --git a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue index 0bdb45d35c9..b3ae671d611 100644 --- a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue +++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue @@ -31,7 +31,8 @@ export default { props: { groupId: { type: Number, - required: true, + required: false, + default: null, }, groupNamespace: { type: String, @@ -57,6 +58,11 @@ export default { required: false, default: () => [], }, + loadingDefaultProjects: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -111,6 +117,9 @@ export default { searchTerm() { this.search(); }, + defaultProjects(projects) { + this.selectedProjects = [...projects]; + }, }, mounted() { this.search(); @@ -202,6 +211,7 @@ export default { ref="projectsDropdown" class="dropdown dropdown-projects" toggle-class="gl-shadow-none" + :loading="loadingDefaultProjects" :show-clear-all="hasSelectedProjects" show-highlighted-items-title highlighted-items-title-class="gl-p-3" @@ -209,6 +219,7 @@ export default { @hide="onHide" > <template #button-content> + <gl-loading-icon v-if="loadingDefaultProjects" class="gl-mr-2" /> <div class="gl-display-flex gl-flex-grow-1"> <gl-avatar v-if="isOnlyOneProjectSelected" diff --git a/app/assets/javascripts/api/packages_api.js b/app/assets/javascripts/api/packages_api.js index 47f51c7e80e..138843a910a 100644 --- a/app/assets/javascripts/api/packages_api.js +++ b/app/assets/javascripts/api/packages_api.js @@ -19,13 +19,7 @@ export function publishPackage( status: 'default', }; - const formData = new FormData(); - formData.append('file', files[0]); - - return axios.put(url, formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, + return axios.put(url, files[0], { params: Object.assign(defaults, options), ...axiosOptions, }); diff --git a/app/assets/javascripts/behaviors/copy_code.js b/app/assets/javascripts/behaviors/copy_code.js index a6e203ea5a2..6d2a4c245cc 100644 --- a/app/assets/javascripts/behaviors/copy_code.js +++ b/app/assets/javascripts/behaviors/copy_code.js @@ -29,7 +29,8 @@ class CopyCodeButton extends HTMLElement { } function addCodeButton() { - [...document.querySelectorAll('pre.code.js-syntax-highlight')] + [...document.querySelectorAll('pre.code.js-syntax-highlight:not(.content-editor-code-block)')] + .filter((el) => el.getAttribute('lang') !== 'mermaid') .filter((el) => !el.closest('.js-markdown-code')) .forEach((el) => { const copyCodeEl = document.createElement('copy-code'); diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js index de248340738..c3c28aeafc0 100644 --- a/app/assets/javascripts/behaviors/copy_to_clipboard.js +++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js @@ -1,7 +1,13 @@ -import Clipboard from 'clipboard'; +import ClipboardJS from 'clipboard'; import $ from 'jquery'; -import { sprintf, __ } from '~/locale'; -import { fixTitle, add, show, once } from '~/tooltips'; + +import { parseBoolean } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import { fixTitle, add, show, hide, once } from '~/tooltips'; + +const CLIPBOARD_SUCCESS_EVENT = 'clipboard-success'; +const CLIPBOARD_ERROR_EVENT = 'clipboard-error'; +const I18N_ERROR_MESSAGE = __('Copy failed. Please manually copy the value.'); function showTooltip(target, title) { const { title: originalTitle } = target.dataset; @@ -9,20 +15,31 @@ function showTooltip(target, title) { once('hidden', (tooltip) => { if (tooltip.target === target) { target.setAttribute('title', originalTitle); + target.setAttribute('aria-label', originalTitle); fixTitle(target); } }); target.setAttribute('title', title); + target.setAttribute('aria-label', title); fixTitle(target); show(target); - setTimeout(() => target.blur(), 1000); + setTimeout(() => { + hide(target); + }, 1000); } function genericSuccess(e) { - // Clear the selection and blur the trigger so it loses its border + // Clear the selection e.clearSelection(); - showTooltip(e.trigger, __('Copied')); + e.trigger.focus(); + e.trigger.dispatchEvent(new Event(CLIPBOARD_SUCCESS_EVENT)); + + const { clipboardHandleTooltip = true } = e.trigger.dataset; + if (parseBoolean(clipboardHandleTooltip)) { + // Update tooltip + showTooltip(e.trigger, __('Copied')); + } } /** @@ -30,17 +47,16 @@ function genericSuccess(e) { * See http://clipboardjs.com/#browser-support */ function genericError(e) { - let key; - if (/Mac/i.test(navigator.userAgent)) { - key = '⌘'; // Command - } else { - key = 'Ctrl'; + e.trigger.dispatchEvent(new Event(CLIPBOARD_ERROR_EVENT)); + + const { clipboardHandleTooltip = true } = e.trigger.dataset; + if (parseBoolean(clipboardHandleTooltip)) { + showTooltip(e.trigger, I18N_ERROR_MESSAGE); } - showTooltip(e.trigger, sprintf(__(`Press %{key}-C to copy`), { key })); } export default function initCopyToClipboard() { - const clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]'); + const clipboard = new ClipboardJS('[data-clipboard-target], [data-clipboard-text]'); clipboard.on('success', genericSuccess); clipboard.on('error', genericError); @@ -74,6 +90,8 @@ export default function initCopyToClipboard() { clipboardData.setData('text/plain', json.text); clipboardData.setData('text/x-gfm', json.gfm); }); + + return clipboard; } /** @@ -89,3 +107,5 @@ export function clickCopyToClipboardButton(btnElement) { btnElement.click(); } + +export { CLIPBOARD_SUCCESS_EVENT, CLIPBOARD_ERROR_EVENT, I18N_ERROR_MESSAGE }; diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js index 4698fcd4d42..c4e09efe263 100644 --- a/app/assets/javascripts/behaviors/markdown/render_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -4,6 +4,7 @@ import initUserPopovers from '../../user_popovers'; import highlightCurrentUser from './highlight_current_user'; import renderMath from './render_math'; import renderMermaid from './render_mermaid'; +import renderSandboxedMermaid from './render_sandboxed_mermaid'; import renderMetrics from './render_metrics'; // Render GitLab flavoured Markdown @@ -13,7 +14,11 @@ import renderMetrics from './render_metrics'; $.fn.renderGFM = function renderGFM() { syntaxHighlight(this.find('.js-syntax-highlight').get()); renderMath(this.find('.js-render-math')); - renderMermaid(this.find('.js-render-mermaid')); + if (gon.features?.sandboxedMermaid) { + renderSandboxedMermaid(this.find('.js-render-mermaid')); + } else { + renderMermaid(this.find('.js-render-mermaid')); + } highlightCurrentUser(this.find('.gfm-project_member').get()); initUserPopovers(this.find('.js-user-link').get()); diff --git a/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js new file mode 100644 index 00000000000..1d54a1b0c04 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js @@ -0,0 +1,234 @@ +import $ from 'jquery'; +import { once, countBy } from 'lodash'; +import { __ } from '~/locale'; +import { + getBaseURL, + relativePathToAbsolute, + setUrlParams, + joinPaths, +} from '~/lib/utils/url_utility'; +import { darkModeEnabled } from '~/lib/utils/color_utils'; +import { setAttributes } from '~/lib/utils/dom_utils'; + +// Renders diagrams and flowcharts from text using Mermaid in any element with the +// `js-render-mermaid` class. +// +// Example markup: +// +// <pre class="js-render-mermaid"> +// graph TD; +// A-- > B; +// A-- > C; +// B-- > D; +// C-- > D; +// </pre> +// + +const SANDBOX_FRAME_PATH = '/-/sandbox/mermaid'; +// This is an arbitrary number; Can be iterated upon when suitable. +const MAX_CHAR_LIMIT = 2000; +// Max # of mermaid blocks that can be rendered in a page. +const MAX_MERMAID_BLOCK_LIMIT = 50; +// Max # of `&` allowed in Chaining of links syntax +const MAX_CHAINING_OF_LINKS_LIMIT = 30; +const BUFFER_IFRAME_HEIGHT = 10; +// Keep a map of mermaid blocks we've already rendered. +const elsProcessingMap = new WeakMap(); +let renderedMermaidBlocks = 0; + +// Pages without any restrictions on mermaid rendering +const PAGES_WITHOUT_RESTRICTIONS = [ + // Group wiki + 'groups:wikis:show', + 'groups:wikis:edit', + 'groups:wikis:create', + + // Project wiki + 'projects:wikis:show', + 'projects:wikis:edit', + 'projects:wikis:create', + + // Project files + 'projects:show', + 'projects:blob:show', +]; + +function shouldLazyLoadMermaidBlock(source) { + /** + * If source contains `&`, which means that it might + * contain Chaining of links a new syntax in Mermaid. + */ + if (countBy(source)['&'] > MAX_CHAINING_OF_LINKS_LIMIT) { + return true; + } + + return false; +} + +function fixElementSource(el) { + // Mermaid doesn't like `<br />` tags, so collapse all like tags into `<br>`, which is parsed correctly. + const source = el.textContent?.replace(/<br\s*\/>/g, '<br>'); + + // Remove any extra spans added by the backend syntax highlighting. + Object.assign(el, { textContent: source }); + + return { source }; +} + +function getSandboxFrameSrc() { + const path = joinPaths(gon.relative_url_root || '', SANDBOX_FRAME_PATH); + if (!darkModeEnabled()) { + return path; + } + const absoluteUrl = relativePathToAbsolute(path, getBaseURL()); + return setUrlParams({ darkMode: darkModeEnabled() }, absoluteUrl); +} + +function renderMermaidEl(el, source) { + const iframeEl = document.createElement('iframe'); + setAttributes(iframeEl, { + src: getSandboxFrameSrc(), + sandbox: 'allow-scripts', + frameBorder: 0, + scrolling: 'no', + width: '100%', + }); + + // Add the original source into the DOM + // to allow Copy-as-GFM to access it. + const sourceEl = document.createElement('text'); + sourceEl.textContent = source; + sourceEl.classList.add('gl-display-none'); + + const wrapper = document.createElement('div'); + wrapper.appendChild(iframeEl); + wrapper.appendChild(sourceEl); + + el.closest('pre').replaceWith(wrapper); + + // Event Listeners + iframeEl.addEventListener('load', () => { + // Potential risk associated with '*' discussed in below thread + // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74414#note_735183398 + iframeEl.contentWindow.postMessage(source, '*'); + }); + + window.addEventListener( + 'message', + (event) => { + if (event.origin !== 'null' || event.source !== iframeEl.contentWindow) { + return; + } + const { h } = event.data; + iframeEl.height = `${h + BUFFER_IFRAME_HEIGHT}px`; + }, + false, + ); +} + +function renderMermaids($els) { + if (!$els.length) return; + + const pageName = document.querySelector('body').dataset.page; + + // A diagram may have been truncated in search results which will cause errors, so abort the render. + if (pageName === 'search:show') return; + + let renderedChars = 0; + + $els.each((i, el) => { + // Skipping all the elements which we've already queued in requestIdleCallback + if (elsProcessingMap.has(el)) { + return; + } + + const { source } = fixElementSource(el); + /** + * Restrict the rendering to a certain amount of character + * and mermaid blocks to prevent mermaidjs from hanging + * up the entire thread and causing a DoS. + */ + if ( + !PAGES_WITHOUT_RESTRICTIONS.includes(pageName) && + ((source && source.length > MAX_CHAR_LIMIT) || + renderedChars > MAX_CHAR_LIMIT || + renderedMermaidBlocks >= MAX_MERMAID_BLOCK_LIMIT || + shouldLazyLoadMermaidBlock(source)) + ) { + const html = ` + <div class="alert gl-alert gl-alert-warning alert-dismissible lazy-render-mermaid-container js-lazy-render-mermaid-container fade show" role="alert"> + <div> + <div> + <div class="js-warning-text"></div> + <div class="gl-alert-actions"> + <button type="button" class="js-lazy-render-mermaid btn gl-alert-action btn-warning btn-md gl-button">Display</button> + </div> + </div> + <button type="button" class="close" data-dismiss="alert" aria-label="Close"> + <span aria-hidden="true">×</span> + </button> + </div> + </div> + `; + + const $parent = $(el).parent(); + + if (!$parent.hasClass('lazy-alert-shown')) { + $parent.after(html); + $parent + .siblings() + .find('.js-warning-text') + .text( + __('Warning: Displaying this diagram might cause performance issues on this page.'), + ); + $parent.addClass('lazy-alert-shown'); + } + + return; + } + + renderedChars += source.length; + renderedMermaidBlocks += 1; + + const requestId = window.requestIdleCallback(() => { + renderMermaidEl(el, source); + }); + + elsProcessingMap.set(el, requestId); + }); +} + +const hookLazyRenderMermaidEvent = once(() => { + $(document.body).on('click', '.js-lazy-render-mermaid', function eventHandler() { + const parent = $(this).closest('.js-lazy-render-mermaid-container'); + const pre = parent.prev(); + + const el = pre.find('.js-render-mermaid'); + + parent.remove(); + + // sandbox update + const element = el.get(0); + const { source } = fixElementSource(element); + + renderMermaidEl(element, source); + }); +}); + +export default function renderMermaid($els) { + if (!$els.length) return; + + const visibleMermaids = $els.filter(function filter() { + return $(this).closest('details').length === 0 && $(this).is(':visible'); + }); + + renderMermaids(visibleMermaids); + + $els.closest('details').one('toggle', function toggle() { + if (this.open) { + renderMermaids($(this).find('.js-render-mermaid')); + } + }); + + hookLazyRenderMermaidEvent(); +} diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js index a548b283142..679940d1317 100644 --- a/app/assets/javascripts/behaviors/preview_markdown.js +++ b/app/assets/javascripts/behaviors/preview_markdown.js @@ -124,13 +124,6 @@ const writeButtonSelector = '.js-md-write-button'; lastTextareaPreviewed = null; const markdownToolbar = $('.md-header-toolbar'); -$.fn.setupMarkdownPreview = function () { - const $form = $(this); - $form.find('textarea.markdown-area').on('input', () => { - markdownPreview.hideReferencedUsers($form); - }); -}; - $(document).on('markdown-preview:show', (e, $form) => { if (!$form) { return; diff --git a/app/assets/javascripts/blob/blob_line_permalink_updater.js b/app/assets/javascripts/blob/blob_line_permalink_updater.js index 11089b299c5..a3dd241604d 100644 --- a/app/assets/javascripts/blob/blob_line_permalink_updater.js +++ b/app/assets/javascripts/blob/blob_line_permalink_updater.js @@ -1,6 +1,6 @@ import { getLocationHash } from '../lib/utils/url_utility'; -const lineNumberRe = /^L[0-9]+/; +const lineNumberRe = /^(L|LC)[0-9]+/; const updateLineNumbersOnBlobPermalinks = (linksToUpdate) => { const hash = getLocationHash(); diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue index 933ad448c77..1645469a218 100644 --- a/app/assets/javascripts/blob/components/blob_header.vue +++ b/app/assets/javascripts/blob/components/blob_header.vue @@ -81,7 +81,7 @@ export default { </blob-filepath> </div> - <div class="gl-display-none gl-sm-display-flex"> + <div class="gl-sm-display-flex file-actions"> <viewer-switcher v-if="showViewerSwitcher" v-model="viewer" /> <slot name="actions"></slot> diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue index cb441a7e491..90d01358451 100644 --- a/app/assets/javascripts/blob/components/blob_header_filepath.vue +++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue @@ -1,4 +1,5 @@ <script> +import { GlBadge } from '@gitlab/ui'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; @@ -7,6 +8,7 @@ export default { components: { FileIcon, ClipboardButton, + GlBadge, }, props: { blob: { @@ -21,6 +23,9 @@ export default { gfmCopyText() { return `\`${this.blob.path}\``; }, + showLfsBadge() { + return this.blob.storedExternally && this.blob.externalStorage === 'lfs'; + }, }, }; </script> @@ -37,8 +42,6 @@ export default { > </template> - <small class="mr-2">{{ blobSize }}</small> - <clipboard-button :text="blob.path" :gfm="gfmCopyText" @@ -46,5 +49,9 @@ export default { category="tertiary" css-class="btn-clipboard btn-transparent lh-100 position-static" /> + + <small class="mr-2">{{ blobSize }}</small> + + <gl-badge v-if="showLfsBadge">{{ __('LFS') }}</gl-badge> </div> </template> diff --git a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue index a5b594fbd88..b2546d47694 100644 --- a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue +++ b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue @@ -53,6 +53,8 @@ export default { icon="code" category="primary" variant="default" + class="js-blob-viewer-switch-btn" + data-viewer="simple" @click="switchToViewer($options.SIMPLE_BLOB_VIEWER)" /> <gl-button @@ -63,6 +65,8 @@ export default { icon="document" category="primary" variant="default" + class="js-blob-viewer-switch-btn" + data-viewer="rich" @click="switchToViewer($options.RICH_BLOB_VIEWER)" /> </gl-button-group> diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/blob/line_highlighter.js index a1f59aa1b54..a1f59aa1b54 100644 --- a/app/assets/javascripts/line_highlighter.js +++ b/app/assets/javascripts/blob/line_highlighter.js diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index 563bed6a6b8..dc821cb9f58 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -72,7 +72,7 @@ export default { data-qa-selector="board_card" :class="{ 'multi-select': multiSelectVisible, - 'user-can-drag': isDraggable, + 'gl-cursor-grab': isDraggable, 'is-disabled': isDisabled, 'is-active': isActive, 'gl-cursor-not-allowed gl-bg-gray-10': item.isLoading, diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index f89f8e5feb8..156029b62b0 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -6,11 +6,12 @@ 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 } from '~/boards/constants'; +import { ISSUABLE, INCIDENT } 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'; import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; +import SidebarSeverity from '~/sidebar/components/severity/sidebar_severity.vue'; import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import SidebarLabelsWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; @@ -29,6 +30,7 @@ export default { SidebarSubscriptionsWidget, SidebarDropdownWidget, SidebarTodoWidget, + SidebarSeverity, MountingPortal, SidebarWeightWidget: () => import('ee_component/sidebar/components/weight/sidebar_weight_widget.vue'), @@ -69,9 +71,15 @@ export default { isIssuableSidebar() { return this.sidebarType === ISSUABLE; }, + isIncidentSidebar() { + return this.activeBoardItem.type === INCIDENT; + }, showSidebar() { return this.isIssuableSidebar && this.isSidebarOpen; }, + sidebarTitle() { + return this.isIncidentSidebar ? __('Incident details') : __('Issue details'); + }, fullPath() { return this.activeBoardItem?.referencePath?.split('#')[0] || ''; }, @@ -138,7 +146,7 @@ export default { @close="handleClose" > <template #title> - <h2 class="gl-my-0 gl-font-size-h2 gl-line-height-24">{{ __('Issue details') }}</h2> + <h2 class="gl-my-0 gl-font-size-h2 gl-line-height-24">{{ sidebarTitle }}</h2> </template> <template #header> <sidebar-todo-widget @@ -159,7 +167,7 @@ export default { @assignees-updated="setAssignees" /> <sidebar-dropdown-widget - v-if="epicFeatureAvailable" + v-if="epicFeatureAvailable && !isIncidentSidebar" :iid="activeBoardItem.iid" issuable-attribute="epic" :workspace-path="projectPathForActiveIssue" @@ -178,7 +186,7 @@ export default { /> <template v-if="!glFeatures.iterationCadences"> <sidebar-dropdown-widget - v-if="iterationFeatureAvailable" + v-if="iterationFeatureAvailable && !isIncidentSidebar" :iid="activeBoardItem.iid" issuable-attribute="iteration" :workspace-path="projectPathForActiveIssue" @@ -190,7 +198,7 @@ export default { </template> <template v-else> <iteration-sidebar-dropdown-widget - v-if="iterationFeatureAvailable" + v-if="iterationFeatureAvailable && !isIncidentSidebar" :iid="activeBoardItem.iid" :workspace-path="projectPathForActiveIssue" :attr-workspace-path="groupPathForActiveIssue" @@ -200,7 +208,7 @@ export default { /> </template> </div> - <board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" /> + <board-sidebar-time-tracker /> <sidebar-date-widget :iid="activeBoardItem.iid" :full-path="fullPath" @@ -209,7 +217,6 @@ export default { /> <sidebar-labels-widget class="block labels" - data-testid="sidebar-labels" :iid="activeBoardItem.iid" :full-path="projectPathForActiveIssue" :allow-label-remove="allowLabelEdit" @@ -227,8 +234,14 @@ export default { > {{ __('None') }} </sidebar-labels-widget> + <sidebar-severity + v-if="isIncidentSidebar" + :iid="activeBoardItem.iid" + :project-path="fullPath" + :initial-severity="activeBoardItem.severity" + /> <sidebar-weight-widget - v-if="weightFeatureAvailable" + v-if="weightFeatureAvailable && !isIncidentSidebar" :iid="activeBoardItem.iid" :full-path="fullPath" :issuable-type="issuableType" diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index 09ec385bbba..2599d1c80b8 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -6,6 +6,7 @@ import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import { AssigneeFilterType } from '~/boards/constants'; export default { i18n: { @@ -37,6 +38,7 @@ export default { authorUsername, labelName, assigneeUsername, + assigneeId, search, milestoneTitle, iterationId, @@ -63,6 +65,13 @@ export default { }); } + if (assigneeId) { + filteredSearchValue.push({ + type: 'assignee', + value: { data: assigneeId, operator: '=' }, + }); + } + if (types) { filteredSearchValue.push({ type: 'type', @@ -211,6 +220,7 @@ export default { authorUsername, labelName, assigneeUsername, + assigneeId, search, milestoneTitle, types, @@ -246,6 +256,7 @@ export default { author_username: authorUsername, 'label_name[]': labelName, assignee_username: assigneeUsername, + assignee_id: assigneeId, milestone_title: milestoneTitle, iteration_id: iterationId, search, @@ -295,7 +306,11 @@ export default { filterParams.authorUsername = filter.value.data; break; case 'assignee': - filterParams.assigneeUsername = filter.value.data; + if (Object.values(AssigneeFilterType).includes(filter.value.data)) { + filterParams.assigneeId = filter.value.data; + } else { + filterParams.assigneeUsername = filter.value.data; + } break; case 'type': filterParams.types = filter.value.data; diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 19004518edf..6835d83a66c 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -263,7 +263,7 @@ export default { > <h3 :class="{ - 'user-can-drag': userCanDrag, + 'gl-cursor-grab': userCanDrag, 'gl-py-3 gl-h-full': list.collapsed && !isSwimlanesHeader, 'gl-border-b-0': list.collapsed || isSwimlanesHeader, 'gl-py-2': list.collapsed && isSwimlanesHeader, diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue index e77aadfa50e..9d19fe57e7a 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue @@ -150,7 +150,7 @@ export default { <div class="gl-display-flex gl-w-full gl-justify-content-space-between gl-mt-5"> <gl-button - variant="success" + variant="confirm" size="small" data-testid="submit-button" :disabled="!title" diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 851b5eca40d..0f290f566ba 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -50,10 +50,13 @@ export const toggleFormEventPrefix = { issue: 'toggle-issue-form-', }; +export const active = 'active'; + export const inactiveId = 0; export const ISSUABLE = 'issuable'; export const LIST = 'list'; +export const INCIDENT = 'INCIDENT'; export const flashAnimationDuration = 2000; @@ -119,6 +122,7 @@ export const FilterFields = { /* eslint-disable @gitlab/require-i18n-strings */ export const AssigneeFilterType = { any: 'Any', + none: 'None', }; export const MilestoneFilterType = { diff --git a/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql index 0963b3fbfaa..6fe8bb799d6 100644 --- a/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql @@ -1,7 +1,7 @@ -query GroupBoardMilestones($fullPath: ID!, $searchTerm: String) { +query GroupBoardMilestones($fullPath: ID!, $searchTerm: String, $state: MilestoneStateEnum) { group(fullPath: $fullPath) { id - milestones(includeAncestors: true, searchTitle: $searchTerm) { + milestones(includeAncestors: true, searchTitle: $searchTerm, state: $state) { nodes { id title diff --git a/app/assets/javascripts/boards/graphql/issue.fragment.graphql b/app/assets/javascripts/boards/graphql/issue.fragment.graphql index 314faae89f8..53fe6fdc59e 100644 --- a/app/assets/javascripts/boards/graphql/issue.fragment.graphql +++ b/app/assets/javascripts/boards/graphql/issue.fragment.graphql @@ -1,35 +1,6 @@ -#import "~/graphql_shared/fragments/milestone.fragment.graphql" -#import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/issue.fragment.graphql" -fragment IssueNode on Issue { +fragment Issue on Issue { id - iid - title - referencePath: reference(full: true) - dueDate - timeEstimate - totalTimeSpent - humanTimeEstimate - humanTotalTimeSpent - emailsDisabled - confidential - hidden - webUrl - relativePosition - milestone { - ...MilestoneFragment - } - assignees { - nodes { - ...User - } - } - labels { - nodes { - id - title - color - description - } - } + ...IssueNode } diff --git a/app/assets/javascripts/boards/graphql/issue_create.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_create.mutation.graphql index c1a2361a4e8..643d5dcfe4c 100644 --- a/app/assets/javascripts/boards/graphql/issue_create.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_create.mutation.graphql @@ -3,7 +3,7 @@ mutation CreateIssue($input: CreateIssueInput!) { createIssue(input: $input) { issue { - ...IssueNode + ...Issue } errors } diff --git a/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql index 570731ecac6..1658cf09085 100644 --- a/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql @@ -21,7 +21,7 @@ mutation issueMoveList( } ) { issue { - ...IssueNode + ...Issue } errors } diff --git a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql index 105f2931caa..994ea894be3 100644 --- a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql +++ b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql @@ -22,7 +22,7 @@ query BoardListsEE( issues(first: $first, filters: $filters, after: $after) { edges { node { - ...IssueNode + ...Issue } } pageInfo { @@ -46,7 +46,7 @@ query BoardListsEE( issues(first: $first, filters: $filters, after: $after) { edges { node { - ...IssueNode + ...Issue } } pageInfo { diff --git a/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql index e456823d78a..d917c7e809d 100644 --- a/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql +++ b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql @@ -1,7 +1,7 @@ -query ProjectBoardMilestones($fullPath: ID!, $searchTerm: String) { +query ProjectBoardMilestones($fullPath: ID!, $searchTerm: String, $state: MilestoneStateEnum) { project(fullPath: $fullPath) { id - milestones(searchTitle: $searchTerm, includeAncestors: true) { + milestones(searchTitle: $searchTerm, includeAncestors: true, state: $state) { nodes { id title diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 1ebfcfc331b..48ca3239cfd 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -15,6 +15,7 @@ import { FilterFields, ListTypeTitles, DraggableItemTypes, + active, } from 'ee_else_ce/boards/constants'; import { formatIssueInput, @@ -209,6 +210,7 @@ export default { const variables = { fullPath, searchTerm, + state: active, }; let query; diff --git a/app/assets/javascripts/branches/branches_delete_modal.js b/app/assets/javascripts/branches/branches_delete_modal.js deleted file mode 100644 index f4c3fa185d8..00000000000 --- a/app/assets/javascripts/branches/branches_delete_modal.js +++ /dev/null @@ -1,53 +0,0 @@ -import $ from 'jquery'; - -const MODAL_SELECTOR = '#modal-delete-branch'; - -class DeleteModal { - constructor() { - this.$modal = $(MODAL_SELECTOR); - this.$toggleBtns = $(`[data-target="${MODAL_SELECTOR}"]`); - this.$branchName = $('.js-branch-name', this.$modal); - this.$confirmInput = $('.js-delete-branch-input', this.$modal); - this.$deleteBtn = $('.js-delete-branch', this.$modal); - this.$notMerged = $('.js-not-merged', this.$modal); - this.bindEvents(); - } - - bindEvents() { - this.$toggleBtns.on('click', this.setModalData.bind(this)); - this.$confirmInput.on('input', this.setDeleteDisabled.bind(this)); - this.$deleteBtn.on('click', this.setDisableDeleteButton.bind(this)); - } - - setModalData(e) { - const branchData = e.currentTarget.dataset; - this.branchName = branchData.branchName || ''; - this.deletePath = branchData.deletePath || ''; - this.isMerged = Boolean(branchData.isMerged); - this.updateModal(); - } - - setDeleteDisabled(e) { - this.$deleteBtn.attr('disabled', e.currentTarget.value !== this.branchName); - } - - setDisableDeleteButton(e) { - if (this.$deleteBtn.is('[disabled]')) { - e.preventDefault(); - e.stopPropagation(); - return false; - } - - return true; - } - - updateModal() { - this.$branchName.text(this.branchName); - this.$confirmInput.val(''); - this.$deleteBtn.attr('href', this.deletePath); - this.$deleteBtn.attr('disabled', true); - this.$notMerged.toggleClass('hidden', this.isMerged); - } -} - -export default DeleteModal; diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue index 9109c010500..a53bba6992d 100644 --- a/app/assets/javascripts/clusters/agents/components/show.vue +++ b/app/assets/javascripts/clusters/agents/components/show.vue @@ -51,16 +51,7 @@ export default { TokenTable, ActivityEvents, }, - props: { - agentName: { - required: true, - type: String, - }, - projectPath: { - required: true, - type: String, - }, - }, + inject: ['agentName', 'projectPath'], data() { return { cursor: { @@ -135,7 +126,7 @@ export default { <activity-events :agent-name="agentName" :project-path="projectPath" /> </gl-tab> - <slot name="ee-security-tab"></slot> + <slot name="ee-security-tab" :cluster-agent-id="clusterAgent.id"></slot> <gl-tab query-param-value="tokens"> <template #title> diff --git a/app/assets/javascripts/clusters/agents/graphql/provider.js b/app/assets/javascripts/clusters/agents/graphql/provider.js new file mode 100644 index 00000000000..8b068fa1eee --- /dev/null +++ b/app/assets/javascripts/clusters/agents/graphql/provider.js @@ -0,0 +1,26 @@ +import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { vulnerabilityLocationTypes } from '~/graphql_shared/fragment_types/vulnerability_location_types'; + +Vue.use(VueApollo); + +// We create a fragment matcher so that we can create a fragment from an interface +// Without this, Apollo throws a heuristic fragment matcher warning +const fragmentMatcher = new IntrospectionFragmentMatcher({ + introspectionQueryResultData: vulnerabilityLocationTypes, +}); + +const defaultClient = createDefaultClient( + {}, + { + cacheConfig: { + fragmentMatcher, + }, + }, +); + +export default new VueApollo({ + defaultClient, +}); diff --git a/app/assets/javascripts/clusters/agents/index.js b/app/assets/javascripts/clusters/agents/index.js index 5796c9e308d..6c7fae274f8 100644 --- a/app/assets/javascripts/clusters/agents/index.js +++ b/app/assets/javascripts/clusters/agents/index.js @@ -1,9 +1,6 @@ import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; import AgentShowPage from 'ee_else_ce/clusters/agents/components/show.vue'; - -Vue.use(VueApollo); +import apolloProvider from './graphql/provider'; export default () => { const el = document.querySelector('#js-cluster-agent-details'); @@ -12,20 +9,19 @@ export default () => { return null; } - const defaultClient = createDefaultClient(); - const { agentName, projectPath, activityEmptyStateImage } = el.dataset; + const { activityEmptyStateImage, agentName, emptyStateSvgPath, projectPath } = el.dataset; return new Vue({ el, - apolloProvider: new VueApollo({ defaultClient }), - provide: { agentName, projectPath, activityEmptyStateImage }, + apolloProvider, + provide: { + activityEmptyStateImage, + agentName, + emptyStateSvgPath, + projectPath, + }, render(createElement) { - return createElement(AgentShowPage, { - props: { - agentName, - projectPath, - }, - }); + return createElement(AgentShowPage); }, }); }; diff --git a/app/assets/javascripts/clusters_list/components/agent_options.vue b/app/assets/javascripts/clusters_list/components/agent_options.vue new file mode 100644 index 00000000000..a364122ba56 --- /dev/null +++ b/app/assets/javascripts/clusters_list/components/agent_options.vue @@ -0,0 +1,200 @@ +<script> +import { + GlDropdown, + GlDropdownItem, + GlModal, + GlModalDirective, + GlSprintf, + GlFormGroup, + GlFormInput, +} from '@gitlab/ui'; +import { s__, __, sprintf } from '~/locale'; +import { DELETE_AGENT_MODAL_ID } from '../constants'; +import deleteAgent from '../graphql/mutations/delete_agent.mutation.graphql'; +import getAgentsQuery from '../graphql/queries/get_agents.query.graphql'; +import { removeAgentFromStore } from '../graphql/cache_update'; + +export default { + i18n: { + dropdownText: __('More options'), + deleteButton: s__('ClusterAgents|Delete agent'), + modalTitle: __('Are you sure?'), + modalBody: s__( + 'ClusterAgents|Are you sure you want to delete this agent? You cannot undo this.', + ), + modalInputLabel: s__('ClusterAgents|To delete the agent, type %{name} to confirm:'), + modalAction: s__('ClusterAgents|Delete'), + modalCancel: __('Cancel'), + successMessage: s__('ClusterAgents|%{name} successfully deleted'), + defaultError: __('An error occurred. Please try again.'), + }, + components: { + GlDropdown, + GlDropdownItem, + GlModal, + GlSprintf, + GlFormGroup, + GlFormInput, + }, + directives: { + GlModalDirective, + }, + inject: ['projectPath'], + props: { + agent: { + required: true, + type: Object, + validator: (value) => ['id', 'name'].every((prop) => value[prop]), + }, + defaultBranchName: { + default: '.noBranch', + required: false, + type: String, + }, + maxAgents: { + default: null, + required: false, + type: Number, + }, + }, + data() { + return { + loading: false, + error: null, + deleteConfirmText: null, + agentName: this.agent.name, + }; + }, + computed: { + getAgentsQueryVariables() { + return { + defaultBranchName: this.defaultBranchName, + first: this.maxAgents, + last: null, + projectPath: this.projectPath, + }; + }, + modalId() { + return sprintf(DELETE_AGENT_MODAL_ID, { + agentName: this.agent.name, + }); + }, + primaryModalProps() { + return { + text: this.$options.i18n.modalAction, + attributes: [ + { disabled: this.loading || this.disableModalSubmit, loading: this.loading }, + { variant: 'danger' }, + ], + }; + }, + cancelModalProps() { + return { + text: this.$options.i18n.modalCancel, + attributes: [], + }; + }, + disableModalSubmit() { + return this.deleteConfirmText !== this.agent.name; + }, + }, + methods: { + async deleteAgent() { + if (this.disableModalSubmit || this.loading) { + return; + } + + this.loading = true; + this.error = null; + + try { + const { errors } = await this.deleteAgentMutation(); + + if (errors.length) { + throw new Error(errors[0]); + } + } catch (error) { + this.error = error?.message || this.$options.i18n.defaultError; + } finally { + this.loading = false; + const successMessage = sprintf(this.$options.i18n.successMessage, { name: this.agentName }); + + this.$toast.show(this.error || successMessage); + + this.$refs.modal.hide(); + } + }, + deleteAgentMutation() { + return this.$apollo + .mutate({ + mutation: deleteAgent, + variables: { + input: { + id: this.agent.id, + }, + }, + update: (store) => { + const deleteClusterAgent = this.agent; + removeAgentFromStore( + store, + deleteClusterAgent, + getAgentsQuery, + this.getAgentsQueryVariables, + ); + }, + }) + + .then(({ data: { clusterAgentDelete } }) => { + return clusterAgentDelete; + }); + }, + hideModal() { + this.loading = false; + this.error = null; + this.deleteConfirmText = null; + }, + }, +}; +</script> + +<template> + <div> + <gl-dropdown + icon="ellipsis_v" + right + :disabled="loading" + :text="$options.i18n.dropdownText" + text-sr-only + category="tertiary" + no-caret + > + <gl-dropdown-item v-gl-modal-directive="modalId"> + {{ $options.i18n.deleteButton }} + </gl-dropdown-item> + </gl-dropdown> + + <gl-modal + ref="modal" + :modal-id="modalId" + :title="$options.i18n.modalTitle" + :action-primary="primaryModalProps" + :action-cancel="cancelModalProps" + size="sm" + @primary="deleteAgent" + @hide="hideModal" + > + <p>{{ $options.i18n.modalBody }}</p> + + <gl-form-group> + <template #label> + <gl-sprintf :message="$options.i18n.modalInputLabel"> + <template #name> + <code>{{ agent.name }}</code> + </template> + </gl-sprintf> + </template> + <gl-form-input v-model="deleteConfirmText" @keydown.enter="deleteAgent" /> + </gl-form-group> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue index 000730ac1ba..695e16b7b4b 100644 --- a/app/assets/javascripts/clusters_list/components/agent_table.vue +++ b/app/assets/javascripts/clusters_list/components/agent_table.vue @@ -1,21 +1,23 @@ <script> -import { - GlLink, - GlModalDirective, - GlTable, - GlIcon, - GlSprintf, - GlTooltip, - GlPopover, -} from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { GlLink, GlTable, GlIcon, GlSprintf, GlTooltip, GlPopover } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { INSTALL_AGENT_MODAL_ID, AGENT_STATUSES } from '../constants'; +import { AGENT_STATUSES } from '../constants'; import { getAgentConfigPath } from '../clusters_util'; +import AgentOptions from './agent_options.vue'; export default { + i18n: { + nameLabel: s__('ClusterAgents|Name'), + statusLabel: s__('ClusterAgents|Connection status'), + lastContactLabel: s__('ClusterAgents|Last contact'), + configurationLabel: s__('ClusterAgents|Configuration'), + optionsLabel: __('Options'), + troubleshootingText: s__('ClusterAgents|Learn how to troubleshoot'), + neverConnectedText: s__('ClusterAgents|Never'), + }, components: { GlLink, GlTable, @@ -24,14 +26,10 @@ export default { GlTooltip, GlPopover, TimeAgoTooltip, - }, - directives: { - GlModalDirective, + AgentOptions, }, mixins: [timeagoMixin], - INSTALL_AGENT_MODAL_ID, AGENT_STATUSES, - troubleshooting_link: helpPagePath('user/clusters/agent/index', { anchor: 'troubleshooting', }), @@ -40,6 +38,16 @@ export default { required: true, type: Array, }, + defaultBranchName: { + default: '.noBranch', + required: false, + type: String, + }, + maxAgents: { + default: null, + required: false, + type: Number, + }, }, computed: { fields() { @@ -47,22 +55,27 @@ export default { return [ { key: 'name', - label: s__('ClusterAgents|Name'), + label: this.$options.i18n.nameLabel, tdClass, }, { key: 'status', - label: s__('ClusterAgents|Connection status'), + label: this.$options.i18n.statusLabel, tdClass, }, { key: 'lastContact', - label: s__('ClusterAgents|Last contact'), + label: this.$options.i18n.lastContactLabel, tdClass, }, { key: 'configuration', - label: s__('ClusterAgents|Configuration'), + label: this.$options.i18n.configurationLabel, + tdClass, + }, + { + key: 'options', + label: this.$options.i18n.optionsLabel, tdClass, }, ]; @@ -118,7 +131,7 @@ export default { </p> <p class="gl-mb-0"> <gl-link :href="$options.troubleshooting_link" target="_blank" class="gl-font-sm"> - {{ s__('ClusterAgents|Learn how to troubleshoot') }}</gl-link + {{ $options.i18n.troubleshootingText }}</gl-link > </p> </gl-popover> @@ -127,7 +140,7 @@ export default { <template #cell(lastContact)="{ item }"> <span data-testid="cluster-agent-last-contact"> <time-ago-tooltip v-if="item.lastContact" :time="item.lastContact" /> - <span v-else>{{ s__('ClusterAgents|Never') }}</span> + <span v-else>{{ $options.i18n.neverConnectedText }}</span> </span> </template> @@ -140,5 +153,13 @@ export default { <span v-else>{{ getAgentConfigPath(item.name) }}</span> </span> </template> + + <template #cell(options)="{ item }"> + <agent-options + :agent="item" + :default-branch-name="defaultBranchName" + :max-agents="maxAgents" + /> + </template> </gl-table> </template> diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue index 45108a28e37..4fc421e7c31 100644 --- a/app/assets/javascripts/clusters_list/components/agents.vue +++ b/app/assets/javascripts/clusters_list/components/agents.vue @@ -151,7 +151,11 @@ export default { <section v-else-if="agentList"> <div v-if="agentList.length"> - <agent-table :agents="agentList" /> + <agent-table + :agents="agentList" + :default-branch-name="defaultBranchName" + :max-agents="cursor.first" + /> <div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5"> <gl-keyset-pagination v-bind="agentPageInfo" @prev="prevPage" @next="nextPage" /> diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index 9b52df74fc5..380a5d0aada 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -106,7 +106,7 @@ export const I18N_AGENT_MODAL = { empty_state: { modalTitle: s__('ClusterAgents|Connect your cluster through the Agent'), modalBody: s__( - "ClusterAgents|To install a new agent, first add the agent's configuration file to this repository. %{linkStart}What's the agent's configuration file?%{linkEnd}", + "ClusterAgents|To install a new agent, first add the agent's configuration file to this repository. %{linkStart}Learn more about installing GitLab Agent.%{linkEnd}", ), enableKasText: s__( "ClusterAgents|Your instance doesn't have the %{linkStart}GitLab Agent Server (KAS)%{linkEnd} set up. Ask a GitLab Administrator to install it.", @@ -242,3 +242,5 @@ export const EVENT_ACTIONS_CHANGE = 'change_tab'; export const MODAL_TYPE_EMPTY = 'empty_state'; export const MODAL_TYPE_REGISTER = 'agent_registration'; + +export const DELETE_AGENT_MODAL_ID = 'delete-agent-modal-%{agentName}'; diff --git a/app/assets/javascripts/clusters_list/graphql/cache_update.js b/app/assets/javascripts/clusters_list/graphql/cache_update.js index 4d12bc8151c..6476b7a6c2f 100644 --- a/app/assets/javascripts/clusters_list/graphql/cache_update.js +++ b/app/assets/javascripts/clusters_list/graphql/cache_update.js @@ -63,3 +63,25 @@ export function addAgentConfigToStore( }); } } + +export function removeAgentFromStore(store, deleteClusterAgent, query, variables) { + if (!hasErrors(deleteClusterAgent)) { + const sourceData = store.readQuery({ + query, + variables, + }); + + const data = produce(sourceData, (draftData) => { + draftData.project.clusterAgents.nodes = draftData.project.clusterAgents.nodes.filter( + ({ id }) => id !== deleteClusterAgent.id, + ); + draftData.project.clusterAgents.count -= 1; + }); + + store.writeQuery({ + query, + variables, + data, + }); + } +} diff --git a/app/assets/javascripts/clusters_list/graphql/mutations/delete_agent.mutation.graphql b/app/assets/javascripts/clusters_list/graphql/mutations/delete_agent.mutation.graphql new file mode 100644 index 00000000000..28387b2a36c --- /dev/null +++ b/app/assets/javascripts/clusters_list/graphql/mutations/delete_agent.mutation.graphql @@ -0,0 +1,5 @@ +mutation deleteClusterAgent($input: ClusterAgentDeleteInput!) { + clusterAgentDelete(input: $input) { + errors + } +} diff --git a/app/assets/javascripts/clusters_list/index.js b/app/assets/javascripts/clusters_list/index.js index 7f1ef37814b..6148483dcb0 100644 --- a/app/assets/javascripts/clusters_list/index.js +++ b/app/assets/javascripts/clusters_list/index.js @@ -1,8 +1,10 @@ +import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import loadClusters from './load_clusters'; import loadMainView from './load_main_view'; +Vue.use(GlToast); Vue.use(VueApollo); export default () => { diff --git a/app/assets/javascripts/confirm_danger_modal.js b/app/assets/javascripts/confirm_danger_modal.js deleted file mode 100644 index ad70d9be16f..00000000000 --- a/app/assets/javascripts/confirm_danger_modal.js +++ /dev/null @@ -1,64 +0,0 @@ -import $ from 'jquery'; -import { Rails } from '~/lib/utils/rails_ujs'; -import { rstrip } from './lib/utils/common_utils'; - -function openConfirmDangerModal($form, $modal, text) { - const $input = $('.js-legacy-confirm-danger-input', $modal); - $input.val(''); - - $('.js-confirm-text', $modal).text(text || ''); - $modal.modal('show'); - - const confirmTextMatch = $('.js-legacy-confirm-danger-match', $modal).text(); - const $submit = $('.js-legacy-confirm-danger-submit', $modal); - $submit.disable(); - $input.focus(); - - // eslint-disable-next-line @gitlab/no-global-event-off - $input.off('input').on('input', function handleInput() { - const confirmText = rstrip($(this).val()); - if (confirmText === confirmTextMatch) { - $submit.enable(); - } else { - $submit.disable(); - } - }); - - // eslint-disable-next-line @gitlab/no-global-event-off - $('.js-legacy-confirm-danger-submit', $modal) - .off('click') - .on('click', () => { - if ($form.data('remote')) { - Rails.fire($form[0], 'submit'); - } else { - $form.submit(); - } - }); -} - -function getModal($btn) { - const $modal = $btn.prev('.modal'); - - if ($modal.length) { - return $modal; - } - - return $('#modal-confirm-danger'); -} - -export default function initConfirmDangerModal() { - $(document).on('click', '.js-legacy-confirm-danger', (e) => { - const $btn = $(e.target); - const checkFieldName = $btn.data('checkFieldName'); - const checkFieldCompareValue = $btn.data('checkCompareValue'); - const checkFieldVal = parseInt($(`[name="${checkFieldName}"]`).val(), 10); - - if (!checkFieldName || checkFieldVal < checkFieldCompareValue) { - e.preventDefault(); - const $form = $btn.closest('form'); - const $modal = getModal($btn); - const text = $btn.data('confirmDangerMessage'); - openConfirmDangerModal($form, $modal, text); - } - }); -} diff --git a/app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue b/app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue index 97b69afd12e..e8829d00986 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue @@ -20,7 +20,7 @@ export default { }; </script> <template> - <node-view-wrapper class="gl-relative code highlight" as="pre"> + <node-view-wrapper class="content-editor-code-block gl-relative code highlight" as="pre"> <span data-testid="frontmatter-label" class="gl-absolute gl-top-0 gl-right-3" diff --git a/app/assets/javascripts/content_editor/constants.js b/app/assets/javascripts/content_editor/constants.js index 4af9dc8e405..5e56078df01 100644 --- a/app/assets/javascripts/content_editor/constants.js +++ b/app/assets/javascripts/content_editor/constants.js @@ -49,3 +49,10 @@ export const LOADING_ERROR_EVENT = 'loadingError'; export const PARSE_HTML_PRIORITY_LOWEST = 1; export const PARSE_HTML_PRIORITY_DEFAULT = 50; export const PARSE_HTML_PRIORITY_HIGHEST = 100; + +export const EXTENSION_PRIORITY_LOWER = 75; +/** + * 100 is the default priority in Tiptap + * https://tiptap.dev/guide/custom-extensions/#priority + */ +export const EXTENSION_PRIORITY_DEFAULT = 100; diff --git a/app/assets/javascripts/content_editor/extensions/code.js b/app/assets/javascripts/content_editor/extensions/code.js index f93c22ad10e..53f6d9b995c 100644 --- a/app/assets/javascripts/content_editor/extensions/code.js +++ b/app/assets/javascripts/content_editor/extensions/code.js @@ -1 +1,12 @@ -export { Code as default } from '@tiptap/extension-code'; +import Code from '@tiptap/extension-code'; +import { EXTENSION_PRIORITY_LOWER } from '../constants'; + +export default Code.extend({ + excludes: null, + /** + * Reduce the rendering priority of the code mark to + * ensure the bold, italic, and strikethrough marks + * are rendered first. + */ + priority: EXTENSION_PRIORITY_LOWER, +}); diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js index ea51bee3ba9..9dc17fcd570 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -19,7 +19,14 @@ export default CodeBlockLowlight.extend({ }; }, renderHTML({ HTMLAttributes }) { - return ['div', ['pre', HTMLAttributes, ['code', {}, 0]]]; + return [ + 'pre', + { + ...HTMLAttributes, + class: `content-editor-code-block ${HTMLAttributes.class}`, + }, + ['code', {}, 0], + ]; }, }).configure({ lowlight, diff --git a/app/assets/javascripts/content_editor/extensions/frontmatter.js b/app/assets/javascripts/content_editor/extensions/frontmatter.js index c09c10bc524..9842027e192 100644 --- a/app/assets/javascripts/content_editor/extensions/frontmatter.js +++ b/app/assets/javascripts/content_editor/extensions/frontmatter.js @@ -14,9 +14,20 @@ export default CodeBlockHighlight.extend({ }, ]; }, + addCommands() { + return { + setFrontmatter: (attributes) => ({ commands }) => { + return commands.setNode(this.name, attributes); + }, + toggleFrontmatter: (attributes) => ({ commands }) => { + return commands.toggleNode(this.name, 'paragraph', attributes); + }, + }; + }, addNodeView() { return new VueNodeViewRenderer(FrontmatterWrapper); }, + addInputRules() { return []; }, diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js index d7fb617f7ee..519f7f168ce 100644 --- a/app/assets/javascripts/content_editor/extensions/image.js +++ b/app/assets/javascripts/content_editor/extensions/image.js @@ -66,6 +66,17 @@ export default Image.extend({ }, ]; }, + renderHTML({ HTMLAttributes }) { + return [ + 'img', + { + src: HTMLAttributes.src, + alt: HTMLAttributes.alt, + title: HTMLAttributes.title, + 'data-canonical-src': HTMLAttributes.canonicalSrc, + }, + ]; + }, addNodeView() { return VueNodeViewRenderer(ImageWrapper); }, diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index 278ef326c7a..d54fb7cded2 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -65,8 +65,8 @@ import { const defaultSerializerConfig = { marks: { [Bold.name]: defaultMarkdownSerializer.marks.strong, - [Code.name]: defaultMarkdownSerializer.marks.code, [Italic.name]: { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true }, + [Code.name]: defaultMarkdownSerializer.marks.code, [Subscript.name]: { open: '<sub>', close: '</sub>', mixable: true }, [Superscript.name]: { open: '<sup>', close: '</sup>', mixable: true }, [InlineDiff.name]: { diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index ed5910fca18..4d5a54c0347 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -1,4 +1,4 @@ -import { uniq } from 'lodash'; +import { uniq, isString } from 'lodash'; const defaultAttrs = { td: { colspan: 1, rowspan: 1, colwidth: null }, @@ -325,9 +325,12 @@ export function renderHardBreak(state, node, parent, index) { export function renderImage(state, node) { const { alt, canonicalSrc, src, title } = node.attrs; - const quotedTitle = title ? ` ${state.quote(title)}` : ''; - state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`); + if (isString(src) || isString(canonicalSrc)) { + const quotedTitle = title ? ` ${state.quote(title)}` : ''; + + state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`); + } } export function renderPlayable(state, node) { diff --git a/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js b/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js index 9b1cb76f845..eb1e4885ba6 100644 --- a/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js +++ b/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js @@ -35,31 +35,33 @@ const trackInputRule = (contentType, inputRule) => { }; const trackInputRulesAndShortcuts = (tiptapExtension) => { - return tiptapExtension.extend({ - addKeyboardShortcuts() { - const shortcuts = this.parent?.() || {}; - const { name } = this; - /** - * We don’t want to track keyboard shortcuts - * that are not deliberately executed to create - * new types of content - */ - const dotNotTrackKeys = [ENTER_KEY, BACKSPACE_KEY]; - const decorated = mapValues(shortcuts, (commandFn, shortcut) => - dotNotTrackKeys.includes(shortcut) - ? commandFn - : trackKeyboardShortcut(name, commandFn, shortcut), - ); + return tiptapExtension + .extend({ + addKeyboardShortcuts() { + const shortcuts = this.parent?.() || {}; + const { name } = this; + /** + * We don’t want to track keyboard shortcuts + * that are not deliberately executed to create + * new types of content + */ + const dotNotTrackKeys = [ENTER_KEY, BACKSPACE_KEY]; + const decorated = mapValues(shortcuts, (commandFn, shortcut) => + dotNotTrackKeys.includes(shortcut) + ? commandFn + : trackKeyboardShortcut(name, commandFn, shortcut), + ); - return decorated; - }, - addInputRules() { - const inputRules = this.parent?.() || []; - const { name } = this; + return decorated; + }, + addInputRules() { + const inputRules = this.parent?.() || []; + const { name } = this; - return inputRules.map((inputRule) => trackInputRule(name, inputRule)); - }, - }); + return inputRules.map((inputRule) => trackInputRule(name, inputRule)); + }, + }) + .configure(tiptapExtension.options); }; export default trackInputRulesAndShortcuts; diff --git a/app/assets/javascripts/crm/components/form.vue b/app/assets/javascripts/crm/components/form.vue new file mode 100644 index 00000000000..b24de1e95e8 --- /dev/null +++ b/app/assets/javascripts/crm/components/form.vue @@ -0,0 +1,232 @@ +<script> +import { GlAlert, GlButton, GlDrawer, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { get as getPropValueByPath, isEmpty } from 'lodash'; +import { produce } from 'immer'; +import { MountingPortal } from 'portal-vue'; +import { __ } from '~/locale'; +import { logError } from '~/lib/logger'; +import { getFirstPropertyValue } from '~/lib/utils/common_utils'; +import { INDEX_ROUTE_NAME } from '../constants'; + +const MSG_SAVE_CHANGES = __('Save changes'); +const MSG_ERROR = __('Something went wrong. Please try again.'); +const MSG_OPTIONAL = __('(optional)'); +const MSG_CANCEL = __('Cancel'); + +/** + * This component is a first iteration towards a general reusable Create/Update component + * + * There's some opportunity to improve cohesion of this module which we are planning + * to address after solidifying the abstraction's requirements. + * + * Please see https://gitlab.com/gitlab-org/gitlab/-/issues/349441 + */ +export default { + components: { + GlAlert, + GlButton, + GlDrawer, + GlFormGroup, + GlFormInput, + MountingPortal, + }, + props: { + drawerOpen: { + type: Boolean, + required: true, + }, + fields: { + type: Array, + required: true, + }, + title: { + type: String, + required: true, + }, + successMessage: { + type: String, + required: true, + }, + mutation: { + type: Object, + required: true, + }, + getQuery: { + type: Object, + required: false, + default: null, + }, + getQueryNodePath: { + type: String, + required: false, + default: null, + }, + existingModel: { + type: Object, + required: false, + default: () => ({}), + }, + additionalCreateParams: { + type: Object, + required: false, + default: () => ({}), + }, + buttonLabel: { + type: String, + required: false, + default: () => MSG_SAVE_CHANGES, + }, + }, + data() { + const initialModel = this.fields.reduce( + (map, field) => + Object.assign(map, { + [field.name]: this.existingModel ? this.existingModel[field.name] : null, + }), + {}, + ); + + return { + model: initialModel, + submitting: false, + errorMessages: [], + }; + }, + computed: { + isEditMode() { + return this.existingModel?.id; + }, + isInvalid() { + const { fields, model } = this; + + return fields.some((field) => { + return field.required && isEmpty(model[field.name]); + }); + }, + variables() { + const { additionalCreateParams, fields, isEditMode, model } = this; + + const variables = fields.reduce( + (map, field) => + Object.assign(map, { + [field.name]: this.formatValue(model, field), + }), + {}, + ); + + if (isEditMode) { + return { input: { id: this.existingModel.id, ...variables } }; + } + + return { input: { ...additionalCreateParams, ...variables } }; + }, + }, + methods: { + formatValue(model, field) { + if (!isEmpty(model[field.name]) && field.input?.type === 'number') { + return parseFloat(model[field.name]); + } + + return model[field.name]; + }, + save() { + const { mutation, variables, close } = this; + + this.submitting = true; + + return this.$apollo + .mutate({ + mutation, + variables, + update: (store, { data }) => { + const { errors, ...result } = getFirstPropertyValue(data); + + if (errors?.length) { + this.errorMessages = errors; + } else { + this.updateCache(store, result); + close(true); + } + }, + }) + .catch((e) => { + logError(e); + this.errorMessages = [MSG_ERROR]; + }) + .finally(() => { + this.submitting = false; + }); + }, + close(success) { + if (success) { + // This is needed so toast perists when route is changed + this.$root.$toast.show(this.successMessage); + } + + this.$router.replace({ name: this.$options.INDEX_ROUTE_NAME }); + }, + updateCache(store, result) { + const { getQuery, isEditMode, getQueryNodePath } = this; + + if (isEditMode || !getQuery) return; + + const sourceData = store.readQuery(getQuery); + + const newData = produce(sourceData, (draftState) => { + getPropValueByPath(draftState, getQueryNodePath).nodes.push(getFirstPropertyValue(result)); + }); + + store.writeQuery({ + ...getQuery, + data: newData, + }); + }, + getFieldLabel(field) { + const optionalSuffix = field.required ? '' : ` ${MSG_OPTIONAL}`; + return field.label + optionalSuffix; + }, + }, + MSG_CANCEL, + INDEX_ROUTE_NAME, +}; +</script> + +<template> + <mounting-portal mount-to="#js-crm-form-portal" append> + <gl-drawer class="gl-drawer-responsive gl-absolute" :open="drawerOpen" @close="close(false)"> + <template #title> + <h3>{{ title }}</h3> + </template> + <gl-alert v-if="errorMessages.length" variant="danger" @dismiss="errorMessages = []"> + <ul class="gl-mb-0! gl-ml-5"> + <li v-for="error in errorMessages" :key="error"> + {{ error }} + </li> + </ul> + </gl-alert> + <form @submit.prevent="save"> + <gl-form-group + v-for="field in fields" + :key="field.name" + :label="getFieldLabel(field)" + :label-for="field.name" + > + <gl-form-input :id="field.name" v-bind="field.input" v-model="model[field.name]" /> + </gl-form-group> + <span class="gl-float-right"> + <gl-button data-testid="cancel-button" @click="close(false)"> + {{ $options.MSG_CANCEL }} + </gl-button> + <gl-button + variant="confirm" + :disabled="isInvalid" + :loading="submitting" + data-testid="save-button" + type="submit" + >{{ buttonLabel }}</gl-button + > + </span> + </form> + </gl-drawer> + </mounting-portal> +</template> diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue index 36430e51dd2..bdfabb8e846 100644 --- a/app/assets/javascripts/cycle_analytics/components/base.vue +++ b/app/assets/javascripts/cycle_analytics/components/base.vue @@ -153,7 +153,7 @@ export default { }; </script> <template> - <div class="cycle-analytics"> + <div> <h3>{{ $options.i18n.pageTitle }}</h3> <div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row"> <path-navigation diff --git a/app/assets/javascripts/cycle_analytics/components/path_navigation.vue b/app/assets/javascripts/cycle_analytics/components/path_navigation.vue index f8f89772fd6..af7334ecf2e 100644 --- a/app/assets/javascripts/cycle_analytics/components/path_navigation.vue +++ b/app/assets/javascripts/cycle_analytics/components/path_navigation.vue @@ -57,7 +57,7 @@ export default { }; </script> <template> - <gl-skeleton-loading v-if="loading" :lines="2" class="h-auto pt-2 pb-1" /> + <gl-skeleton-loading v-if="loading" :lines="2" /> <gl-path v-else :key="selectedStage.id" :items="stages" @selected="onSelectStage"> <template #default="{ pathItem, pathId }"> <gl-popover diff --git a/app/assets/javascripts/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/cycle_analytics/components/stage_table.vue index fc4dfafb809..8f7a3f99bab 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_table.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_table.vue @@ -32,6 +32,9 @@ const WORKFLOW_COLUMN_TITLES = { mergeRequests: { ...DEFAULT_WORKFLOW_TITLE_PROPERTIES, label: __('Merge requests') }, }; +const fullProjectPath = ({ namespaceFullPath = '', projectPath }) => + namespaceFullPath.split('/').length > 1 ? `${namespaceFullPath}/${projectPath}` : projectPath; + export default { name: 'StageTable', components: { @@ -89,6 +92,11 @@ export default { required: false, default: true, }, + includeProjectName: { + type: Boolean, + required: false, + default: false, + }, }, data() { if (this.pagination) { @@ -144,8 +152,15 @@ export default { isMrLink(url = '') { return url.includes('/merge_request'); }, - itemId({ url, iid }) { - return this.isMrLink(url) ? `!${iid}` : `#${iid}`; + itemId({ iid, projectPath, namespaceFullPath = '' }, separator = '#') { + const prefix = this.includeProjectName + ? fullProjectPath({ namespaceFullPath, projectPath }) + : ''; + return `${prefix}${separator}${iid}`; + }, + itemDisplayName(item) { + const separator = this.isMrLink(item.url) ? '!' : '#'; + return this.itemId(item, separator); }, itemTitle(item) { return item.title || item.name; @@ -201,8 +216,11 @@ export default { <div data-testid="vsa-stage-event"> <div v-if="item.id" data-testid="vsa-stage-content"> <p class="gl-m-0"> - <gl-link class="gl-text-black-normal pipeline-id" :href="item.url" - >#{{ item.id }}</gl-link + <gl-link + data-testid="vsa-stage-event-link" + class="gl-text-black-normal" + :href="item.url" + >{{ itemId(item.id, '#') }}</gl-link > <gl-icon :size="16" name="fork" /> <gl-link @@ -240,7 +258,12 @@ export default { <gl-link class="gl-text-black-normal" :href="item.url">{{ itemTitle(item) }}</gl-link> </h5> <p class="gl-m-0"> - <gl-link class="gl-text-black-normal" :href="item.url">{{ itemId(item) }}</gl-link> + <gl-link + data-testid="vsa-stage-event-link" + class="gl-text-black-normal" + :href="item.url" + >{{ itemDisplayName(item) }}</gl-link + > <span class="gl-font-lg">·</span> <span data-testid="vsa-stage-event-date"> {{ s__('OpenedNDaysAgo|Opened') }} diff --git a/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue b/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue index 8610dfc2b03..64461797c46 100644 --- a/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue +++ b/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue @@ -59,7 +59,9 @@ export default { }; </script> <template> - <div class="gl-mt-3 gl-py-2 gl-px-3 bg-gray-light border-top border-bottom"> + <div + class="gl-mt-3 gl-py-2 gl-px-3 gl-bg-gray-10 gl-border-b-1 gl-border-b-solid gl-border-t-1 gl-border-t-solid gl-border-gray-100" + > <filter-bar data-testid="vsa-filter-bar" class="filtered-search-box gl-display-flex gl-mb-2 gl-mr-3 gl-border-none" diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue index 10976202d06..7fefbab977d 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue @@ -7,6 +7,7 @@ import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vu import { updateGlobalTodoCount } from '~/vue_shared/components/sidebar/todo_toggle/utils'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue'; +import { isLoggedIn } from '~/lib/utils/common_utils'; import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants'; import createNoteMutation from '../../graphql/mutations/create_note.mutation.graphql'; import toggleResolveDiscussionMutation from '../../graphql/mutations/toggle_resolve_discussion.mutation.graphql'; @@ -17,6 +18,7 @@ import { hasErrors } from '../../utils/cache_update'; import { extractDesign } from '../../utils/design_management_utils'; import { ADD_DISCUSSION_COMMENT_ERROR } from '../../utils/error_messages'; import DesignNote from './design_note.vue'; +import DesignNoteSignedOut from './design_note_signed_out.vue'; import DesignReplyForm from './design_reply_form.vue'; import ToggleRepliesWidget from './toggle_replies_widget.vue'; @@ -24,6 +26,7 @@ export default { components: { ApolloMutation, DesignNote, + DesignNoteSignedOut, ReplyPlaceholder, DesignReplyForm, GlIcon, @@ -55,6 +58,14 @@ export default { required: false, default: '', }, + registerPath: { + type: String, + required: true, + }, + signInPath: { + type: String, + required: true, + }, resolvedDiscussionsExpanded: { type: Boolean, required: true, @@ -93,6 +104,7 @@ export default { isResolving: false, shouldChangeResolvedStatus: false, areRepliesCollapsed: this.discussion.resolved, + isLoggedIn: isLoggedIn(), }; }, computed: { @@ -226,7 +238,7 @@ export default { :class="{ 'gl-bg-blue-50': isDiscussionActive }" @error="$emit('update-note-error', $event)" > - <template v-if="discussion.resolvable" #resolve-discussion> + <template v-if="isLoggedIn && discussion.resolvable" #resolve-discussion> <button v-gl-tooltip :class="{ 'is-active': discussion.resolved }" @@ -269,38 +281,47 @@ export default { :class="{ 'gl-bg-blue-50': isDiscussionActive }" @error="$emit('update-note-error', $event)" /> - <li v-show="isReplyPlaceholderVisible" class="reply-wrapper discussion-reply-holder"> - <reply-placeholder - v-if="!isFormVisible" - class="qa-discussion-reply" - :placeholder-text="__('Reply…')" - @focus="showForm" - /> - <apollo-mutation - v-else - #default="{ mutate, loading }" - :mutation="$options.createNoteMutation" - :variables="{ - input: mutationPayload, - }" - @done="onDone" - @error="onCreateNoteError" - > - <design-reply-form - v-model="discussionComment" - :is-saving="loading" - :markdown-preview-path="markdownPreviewPath" - @submit-form="mutate" - @cancel-form="hideForm" + <li + v-show="isReplyPlaceholderVisible" + class="reply-wrapper discussion-reply-holder" + :class="{ 'gl-bg-gray-10': !isLoggedIn }" + > + <template v-if="!isLoggedIn"> + <design-note-signed-out :register-path="registerPath" :sign-in-path="signInPath" /> + </template> + <template v-else> + <reply-placeholder + v-if="!isFormVisible" + class="qa-discussion-reply" + :placeholder-text="__('Reply…')" + @focus="showForm" + /> + <apollo-mutation + v-else + #default="{ mutate, loading }" + :mutation="$options.createNoteMutation" + :variables="{ + input: mutationPayload, + }" + @done="onDone" + @error="onCreateNoteError" > - <template v-if="discussion.resolvable" #resolve-checkbox> - <label data-testid="resolve-checkbox"> - <input v-model="shouldChangeResolvedStatus" type="checkbox" /> - {{ resolveCheckboxText }} - </label> - </template> - </design-reply-form> - </apollo-mutation> + <design-reply-form + v-model="discussionComment" + :is-saving="loading" + :markdown-preview-path="markdownPreviewPath" + @submit-form="mutate" + @cancel-form="hideForm" + > + <template v-if="discussion.resolvable" #resolve-checkbox> + <label data-testid="resolve-checkbox"> + <input v-model="shouldChangeResolvedStatus" type="checkbox" /> + {{ resolveCheckboxText }} + </label> + </template> + </design-reply-form> + </apollo-mutation> + </template> </li> </ul> </div> diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note_signed_out.vue b/app/assets/javascripts/design_management/components/design_notes/design_note_signed_out.vue new file mode 100644 index 00000000000..f0812e62bba --- /dev/null +++ b/app/assets/javascripts/design_management/components/design_notes/design_note_signed_out.vue @@ -0,0 +1,50 @@ +<script> +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlSprintf, + GlLink, + }, + props: { + registerPath: { + type: String, + required: true, + }, + signInPath: { + type: String, + required: true, + }, + isAddDiscussion: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + signedOutText() { + return this.isAddDiscussion + ? __( + 'Please %{registerLinkStart}register%{registerLinkEnd} or %{signInLinkStart}sign in%{signInLinkEnd} to start a new discussion.', + ) + : __( + 'Please %{registerLinkStart}register%{registerLinkEnd} or %{signInLinkStart}sign in%{signInLinkEnd} to reply.', + ); + }, + }, +}; +</script> + +<template> + <div class="disabled-comment text-center"> + <gl-sprintf :message="signedOutText"> + <template #registerLink="{ content }"> + <gl-link :href="registerPath">{{ content }}</gl-link> + </template> + <template #signInLink="{ content }"> + <gl-link :href="signInPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> +</template> diff --git a/app/assets/javascripts/design_management/components/design_presentation.vue b/app/assets/javascripts/design_management/components/design_presentation.vue index 11d2f3b2e37..5116bacefa5 100644 --- a/app/assets/javascripts/design_management/components/design_presentation.vue +++ b/app/assets/javascripts/design_management/components/design_presentation.vue @@ -1,5 +1,6 @@ <script> import { throttle } from 'lodash'; +import { isLoggedIn } from '~/lib/utils/common_utils'; import DesignOverlay from './design_overlay.vue'; import DesignImage from './image.vue'; @@ -54,6 +55,7 @@ export default { initialLoad: true, lastDragPosition: null, isDraggingDesign: false, + isLoggedIn: isLoggedIn(), }; }, computed: { @@ -311,7 +313,7 @@ export default { :position="overlayPosition" :notes="discussionStartingNotes" :current-comment-form="currentCommentForm" - :disable-commenting="isDraggingDesign" + :disable-commenting="!isLoggedIn || isDraggingDesign" :resolved-discussions-expanded="resolvedDiscussionsExpanded" @openCommentForm="openCommentForm" @closeCommentForm="closeCommentForm" diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue index ced76eb4843..6d0ed3b08a3 100644 --- a/app/assets/javascripts/design_management/components/design_sidebar.vue +++ b/app/assets/javascripts/design_management/components/design_sidebar.vue @@ -1,7 +1,7 @@ <script> import { GlCollapse, GlButton, GlPopover } from '@gitlab/ui'; import Cookies from 'js-cookie'; -import { parseBoolean } from '~/lib/utils/common_utils'; +import { parseBoolean, isLoggedIn } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import Participants from '~/sidebar/components/participants/participants.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -9,11 +9,13 @@ import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants'; import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql'; import { extractDiscussions, extractParticipants } from '../utils/design_management_utils'; import DesignDiscussion from './design_notes/design_discussion.vue'; +import DesignNoteSignedOut from './design_notes/design_note_signed_out.vue'; import DesignTodoButton from './design_todo_button.vue'; export default { components: { DesignDiscussion, + DesignNoteSignedOut, Participants, GlCollapse, GlButton, @@ -28,6 +30,12 @@ export default { issueIid: { default: '', }, + registerPath: { + default: '', + }, + signInPath: { + default: '', + }, }, props: { design: { @@ -47,6 +55,7 @@ export default { return { isResolvedCommentsPopoverHidden: parseBoolean(Cookies.get(this.$options.cookieKey)), discussionWithOpenForm: '', + isLoggedIn: isLoggedIn(), }; }, computed: { @@ -134,12 +143,19 @@ export default { class="gl-mb-4" /> <h2 - v-if="unresolvedDiscussions.length === 0" + v-if="isLoggedIn && unresolvedDiscussions.length === 0" class="new-discussion-disclaimer gl-font-base gl-m-0 gl-mb-4" data-testid="new-discussion-disclaimer" > {{ s__("DesignManagement|Click the image where you'd like to start a new discussion") }} </h2> + <design-note-signed-out + v-if="!isLoggedIn" + class="gl-mb-4" + :register-path="registerPath" + :sign-in-path="signInPath" + :is-add-discussion="true" + /> <design-discussion v-for="discussion in unresolvedDiscussions" :key="discussion.id" @@ -147,6 +163,8 @@ export default { :design-id="$route.params.id" :noteable-id="design.id" :markdown-preview-path="markdownPreviewPath" + :register-path="registerPath" + :sign-in-path="signInPath" :resolved-discussions-expanded="resolvedDiscussionsExpanded" :discussion-with-open-form="discussionWithOpenForm" data-testid="unresolved-discussion" @@ -197,6 +215,8 @@ export default { :design-id="$route.params.id" :noteable-id="design.id" :markdown-preview-path="markdownPreviewPath" + :register-path="registerPath" + :sign-in-path="signInPath" :resolved-discussions-expanded="resolvedDiscussionsExpanded" :discussion-with-open-form="discussionWithOpenForm" data-testid="resolved-discussion" diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js index 11666587265..4ae76050aa5 100644 --- a/app/assets/javascripts/design_management/index.js +++ b/app/assets/javascripts/design_management/index.js @@ -8,7 +8,7 @@ import createRouter from './router'; export default () => { const el = document.querySelector('.js-design-management'); - const { issueIid, projectPath, issuePath } = el.dataset; + const { issueIid, projectPath, issuePath, registerPath, signInPath } = el.dataset; const router = createRouter(issuePath); apolloProvider.clients.defaultClient.cache.writeQuery({ @@ -29,6 +29,8 @@ export default () => { provide: { projectPath, issueIid, + registerPath, + signInPath, }, mounted() { performanceMarkAndMeasure({ diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index da918947cc5..442807587d5 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -182,7 +182,7 @@ export default { class="gl-mr-3" @click="expandAllFiles" > - {{ __('Expand all') }} + {{ __('Expand all files') }} </gl-button> <settings-dropdown /> </div> diff --git a/app/assets/javascripts/diffs/components/image_diff_overlay.vue b/app/assets/javascripts/diffs/components/image_diff_overlay.vue index 5572338908f..eede8e52292 100644 --- a/app/assets/javascripts/diffs/components/image_diff_overlay.vue +++ b/app/assets/javascripts/diffs/components/image_diff_overlay.vue @@ -4,8 +4,8 @@ import { isArray } from 'lodash'; import { mapActions, mapGetters } from 'vuex'; import imageDiffMixin from 'ee_else_ce/diffs/mixins/image_diff'; -function calcPercent(pos, size, renderedSize) { - return (((pos / size) * 100) / ((renderedSize / size) * 100)) * 100; +function calcPercent(pos, renderedSize) { + return (100 * pos) / renderedSize; } export default { @@ -65,8 +65,8 @@ export default { ...mapActions('diffs', ['openDiffFileCommentForm']), getImageDimensions() { return { - width: this.$parent.width, - height: this.$parent.height, + width: Math.round(this.$parent.width), + height: Math.round(this.$parent.height), }; }, getPositionForObject(meta) { @@ -87,15 +87,15 @@ export default { }, clickedImage(x, y) { const { width, height } = this.getImageDimensions(); - const xPercent = calcPercent(x, width, this.renderedWidth); - const yPercent = calcPercent(y, height, this.renderedHeight); + const xPercent = calcPercent(x, this.renderedWidth); + const yPercent = calcPercent(y, this.renderedHeight); this.openDiffFileCommentForm({ fileHash: this.fileHash, width, height, - x: width * (xPercent / 100), - y: height * (yPercent / 100), + x: Math.round(width * (xPercent / 100)), + y: Math.round(height * (yPercent / 100)), xPercent, yPercent, }); diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 7c7127dfa44..491c2ced358 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -51,7 +51,6 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { // Add dropzone area to the form. const $mdArea = formTextarea.closest('.md-area'); - form.setupMarkdownPreview(); const $formDropzone = form.find('.div-dropzone'); $formDropzone.parent().addClass('div-dropzone-wrapper'); $formDropzone.append(divHover); diff --git a/app/assets/javascripts/editor/source_editor.js b/app/assets/javascripts/editor/source_editor.js index 57e2b0da565..fa749112ab5 100644 --- a/app/assets/javascripts/editor/source_editor.js +++ b/app/assets/javascripts/editor/source_editor.js @@ -149,7 +149,7 @@ export default class SourceEditor { }); this.instances.push(instance); - el.dispatchEvent(new CustomEvent(EDITOR_READY_EVENT, { instance })); + el.dispatchEvent(new CustomEvent(EDITOR_READY_EVENT, { detail: { instance } })); return instance; } diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue index dc3eac0cd0c..686b5ffff9e 100644 --- a/app/assets/javascripts/emoji/components/picker.vue +++ b/app/assets/javascripts/emoji/components/picker.vue @@ -28,6 +28,16 @@ export default { required: false, default: () => [], }, + right: { + type: Boolean, + required: false, + default: true, + }, + boundary: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -62,7 +72,7 @@ export default { addToFrequentlyUsed(name); }, getBoundaryElement() { - return document.querySelector('.content-wrapper') || 'scrollParent'; + return this.boundary || document.querySelector('.content-wrapper') || 'scrollParent'; }, onSearchInput() { this.$refs.virtualScoller.setScrollTop(0); @@ -87,7 +97,7 @@ export default { menu-class="dropdown-extended-height" category="secondary" no-flip - right + :right="right" lazy @shown="$emit('shown')" @hidden="$emit('hidden')" @@ -115,7 +125,7 @@ export default { :aria-label="category.name" @click="scrollToCategory(category.name)" > - <gl-icon :name="category.icon" :size="12" /> + <gl-icon :name="category.icon" /> </button> </div> <emoji-list :search-value="searchValue"> diff --git a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue index 0e556f093e2..ce919f73858 100644 --- a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue +++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue @@ -99,8 +99,7 @@ export default { }; }, isLastDeployment() { - // eslint-disable-next-line @gitlab/require-i18n-strings - return this.environment?.isLastDeployment || this.environment?.lastDeployment?.['last?']; + return this.environment?.isLastDeployment || this.environment?.lastDeployment?.isLast; }, }, methods: { diff --git a/app/assets/javascripts/environments/components/deployment.vue b/app/assets/javascripts/environments/components/deployment.vue new file mode 100644 index 00000000000..ef43ca6bc33 --- /dev/null +++ b/app/assets/javascripts/environments/components/deployment.vue @@ -0,0 +1,25 @@ +<script> +import DeploymentStatusBadge from './deployment_status_badge.vue'; + +export default { + components: { + DeploymentStatusBadge, + }, + props: { + deployment: { + type: Object, + required: true, + }, + }, + computed: { + status() { + return this.deployment?.status; + }, + }, +}; +</script> +<template> + <div> + <deployment-status-badge v-if="status" :status="status" /> + </div> +</template> diff --git a/app/assets/javascripts/environments/components/deployment_status_badge.vue b/app/assets/javascripts/environments/components/deployment_status_badge.vue new file mode 100644 index 00000000000..5a026911766 --- /dev/null +++ b/app/assets/javascripts/environments/components/deployment_status_badge.vue @@ -0,0 +1,60 @@ +<script> +import { GlBadge } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +const STATUS_TEXT = { + created: s__('Deployment|Created'), + running: s__('Deployment|Running'), + success: s__('Deployment|Success'), + failed: s__('Deployment|Failed'), + canceled: s__('Deployment|Cancelled'), + skipped: s__('Deployment|Skipped'), + blocked: s__('Deployment|Waiting'), +}; + +const STATUS_VARIANT = { + success: 'success', + running: 'info', + failed: 'danger', + created: 'neutral', + canceled: 'neutral', + skipped: 'neutral', + blocked: 'neutral', +}; + +const STATUS_ICON = { + success: 'status_success', + running: 'status_running', + failed: 'status_failed', + created: 'status_created', + canceled: 'status_canceled', + skipped: 'status_skipped', + blocked: 'status_manual', +}; + +export default { + components: { + GlBadge, + }, + props: { + status: { + type: String, + required: true, + }, + }, + computed: { + icon() { + return STATUS_ICON[this.status]; + }, + text() { + return STATUS_TEXT[this.status]; + }, + variant() { + return STATUS_VARIANT[this.status]; + }, + }, +}; +</script> +<template> + <gl-badge v-if="status" :icon="icon" :variant="variant">{{ text }}</gl-badge> +</template> diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index 2d98f00433a..98c95507168 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -1,8 +1,9 @@ <script> -import { GlDropdown, GlDropdownItem, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { formatTime } from '~/lib/utils/datetime_utility'; import { __, s__, sprintf } from '~/locale'; import eventHub from '../event_hub'; +import actionMutation from '../graphql/mutations/action.mutation.graphql'; export default { directives: { @@ -12,7 +13,6 @@ export default { GlDropdown, GlDropdownItem, GlIcon, - GlLoadingIcon, }, props: { actions: { @@ -20,6 +20,11 @@ export default { required: false, default: () => [], }, + graphql: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -49,7 +54,11 @@ export default { this.isLoading = true; - eventHub.$emit('postAction', { endpoint: action.playPath }); + if (this.graphql) { + this.$apollo.mutate({ mutation: actionMutation, variables: { action } }); + } else { + eventHub.$emit('postAction', { endpoint: action.playPath }); + } }, isActionDisabled(action) { @@ -70,18 +79,16 @@ export default { <template> <gl-dropdown v-gl-tooltip + :text="title" :title="title" + :loading="isLoading" :aria-label="title" - :disabled="isLoading" + icon="play" + text-sr-only right data-container="body" data-testid="environment-actions-button" > - <template #button-content> - <gl-icon name="play" /> - <gl-icon name="chevron-down" /> - <gl-loading-icon v-if="isLoading" size="sm" /> - </template> <gl-dropdown-item v-for="(action, i) in actions" :key="i" diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index be9bfb50de5..cfe35d26b94 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlTooltipDirective, GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlDropdown, GlTooltipDirective, GlIcon, GlLink, GlSprintf, GlBadge } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { __, s__, sprintf } from '~/locale'; @@ -30,6 +30,7 @@ export default { CommitComponent, ExternalUrlComponent, GlDropdown, + GlBadge, GlIcon, GlLink, GlSprintf, @@ -621,9 +622,9 @@ export default { <span v-if="model.size === 1">{{ model.name }}</span> <span v-else>{{ model.name_without_type }}</span> </a> - <span v-if="isProtected" class="badge badge-success"> - {{ s__('Environments|protected') }} - </span> + <gl-badge v-if="isProtected" variant="success">{{ + s__('Environments|protected') + }}</gl-badge> </span> <span v-else @@ -639,7 +640,7 @@ export default { <span> {{ model.folderName }} </span> - <span class="badge badge-pill"> {{ model.size }} </span> + <gl-badge>{{ model.size }}</gl-badge> </span> </div> diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue index 0d4a1e76eb8..17a70fd0c34 100644 --- a/app/assets/javascripts/environments/components/environment_stop.vue +++ b/app/assets/javascripts/environments/components/environment_stop.vue @@ -8,6 +8,8 @@ import { GlTooltipDirective, GlButton, GlModalDirective } from '@gitlab/ui'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; +import setEnvironmentToStopMutation from '../graphql/mutations/set_environment_to_stop.mutation.graphql'; +import isEnvironmentStoppingQuery from '../graphql/queries/is_environment_stopping.query.graphql'; export default { components: { @@ -22,6 +24,19 @@ export default { type: Object, required: true, }, + graphql: { + type: Boolean, + required: false, + default: false, + }, + }, + apollo: { + isEnvironmentStopping: { + query: isEnvironmentStoppingQuery, + variables() { + return { environment: this.environment }; + }, + }, }, i18n: { title: s__('Environments|Stop environment'), @@ -30,6 +45,7 @@ export default { data() { return { isLoading: false, + isEnvironmentStopping: false, }; }, mounted() { @@ -41,7 +57,14 @@ export default { methods: { onClick() { this.$root.$emit(BV_HIDE_TOOLTIP, this.$options.stopEnvironmentTooltipId); - eventHub.$emit('requestStopEnvironment', this.environment); + if (this.graphql) { + this.$apollo.mutate({ + mutation: setEnvironmentToStopMutation, + variables: { environment: this.environment }, + }); + } else { + eventHub.$emit('requestStopEnvironment', this.environment); + } }, onStopEnvironment(environment) { if (this.environment.id === environment.id) { @@ -56,7 +79,7 @@ export default { <gl-button v-gl-tooltip="{ id: $options.stopEnvironmentTooltipId }" v-gl-modal-directive="'stop-environment-modal'" - :loading="isLoading" + :loading="isLoading || isEnvironmentStopping" :title="$options.i18n.title" :aria-label="$options.i18n.title" icon="stop" diff --git a/app/assets/javascripts/environments/components/new_environment_folder.vue b/app/assets/javascripts/environments/components/new_environment_folder.vue index fe3d6f1e8ca..0d3867a4d74 100644 --- a/app/assets/javascripts/environments/components/new_environment_folder.vue +++ b/app/assets/javascripts/environments/components/new_environment_folder.vue @@ -2,9 +2,11 @@ import { GlButton, GlCollapse, GlIcon, GlBadge, GlLink } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import folderQuery from '../graphql/queries/folder.query.graphql'; +import EnvironmentItem from './new_environment_item.vue'; export default { components: { + EnvironmentItem, GlButton, GlCollapse, GlIcon, @@ -51,17 +53,26 @@ export default { folderPath() { return this.nestedEnvironment.latest.folderPath; }, + environments() { + return this.folder?.environments; + }, }, methods: { toggleCollapse() { this.visible = !this.visible; }, + isFirstEnvironment(index) { + return index === 0; + }, }, }; </script> <template> - <div class="gl-border-b-solid gl-border-gray-100 gl-border-1 gl-px-3 gl-pt-3 gl-pb-5"> - <div class="gl-w-full gl-display-flex gl-align-items-center"> + <div + :class="{ 'gl-pb-5': !visible }" + class="gl-border-b-solid gl-border-gray-100 gl-border-1 gl-pt-3" + > + <div class="gl-w-full gl-display-flex gl-align-items-center gl-px-3"> <gl-button class="gl-mr-4 gl-fill-current-color gl-text-gray-500" :aria-label="label" @@ -77,6 +88,15 @@ export default { <gl-badge size="sm" class="gl-mr-auto">{{ count }}</gl-badge> <gl-link v-if="visible" :href="folderPath">{{ $options.i18n.link }}</gl-link> </div> - <gl-collapse :visible="visible" /> + <gl-collapse :visible="visible"> + <environment-item + v-for="(environment, index) in environments" + :key="environment.name" + :environment="environment" + :class="{ 'gl-mt-5': isFirstEnvironment(index) }" + class="gl-border-gray-100 gl-border-t-solid gl-border-1 gl-pt-3" + in-folder + /> + </gl-collapse> </div> </template> diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue new file mode 100644 index 00000000000..d3624103c13 --- /dev/null +++ b/app/assets/javascripts/environments/components/new_environment_item.vue @@ -0,0 +1,265 @@ +<script> +import { + GlCollapse, + GlDropdown, + GlButton, + GlLink, + GlTooltipDirective as GlTooltip, +} from '@gitlab/ui'; +import { __ } from '~/locale'; +import { truncate } from '~/lib/utils/text_utility'; +import isLastDeployment from '../graphql/queries/is_last_deployment.query.graphql'; +import ExternalUrl from './environment_external_url.vue'; +import Actions from './environment_actions.vue'; +import StopComponent from './environment_stop.vue'; +import Rollback from './environment_rollback.vue'; +import Pin from './environment_pin.vue'; +import Monitoring from './environment_monitoring.vue'; +import Terminal from './environment_terminal_button.vue'; +import Delete from './environment_delete.vue'; +import Deployment from './deployment.vue'; + +export default { + components: { + GlCollapse, + GlDropdown, + GlButton, + GlLink, + Actions, + Deployment, + ExternalUrl, + StopComponent, + Rollback, + Monitoring, + Pin, + Terminal, + Delete, + }, + directives: { + GlTooltip, + }, + props: { + environment: { + required: true, + type: Object, + }, + inFolder: { + required: false, + default: false, + type: Boolean, + }, + }, + apollo: { + isLastDeployment: { + query: isLastDeployment, + variables() { + return { environment: this.environment }; + }, + }, + }, + i18n: { + collapse: __('Collapse'), + expand: __('Expand'), + }, + data() { + return { visible: false }; + }, + computed: { + icon() { + return this.visible ? 'angle-down' : 'angle-right'; + }, + externalUrl() { + return this.environment.externalUrl; + }, + name() { + return this.inFolder ? this.environment.nameWithoutType : this.environment.name; + }, + label() { + return this.visible ? this.$options.i18n.collapse : this.$options.i18n.expand; + }, + lastDeployment() { + return this.environment?.lastDeployment; + }, + upcomingDeployment() { + return this.environment?.upcomingDeployment; + }, + actions() { + if (!this.lastDeployment) { + return []; + } + const { manualActions = [], scheduledActions = [] } = this.lastDeployment; + const combinedActions = [...manualActions, ...scheduledActions]; + return combinedActions.map((action) => ({ + ...action, + })); + }, + canStop() { + return this.environment?.canStop; + }, + retryPath() { + return this.lastDeployment?.deployable?.retryPath; + }, + hasExtraActions() { + return Boolean( + this.retryPath || + this.canShowAutoStopDate || + this.metricsPath || + this.terminalPath || + this.canDeleteEnvironment, + ); + }, + canShowAutoStopDate() { + if (!this.environment?.autoStopAt) { + return false; + } + + const autoStopDate = new Date(this.environment?.autoStopAt); + const now = new Date(); + + return now < autoStopDate; + }, + autoStopPath() { + return this.environment?.cancelAutoStopPath ?? ''; + }, + metricsPath() { + return this.environment?.metricsPath ?? ''; + }, + terminalPath() { + return this.environment?.terminalPath ?? ''; + }, + canDeleteEnvironment() { + return Boolean(this.environment?.canDelete && this.environment?.deletePath); + }, + displayName() { + return truncate(this.name, 80); + }, + }, + methods: { + toggleCollapse() { + this.visible = !this.visible; + }, + }, + deploymentClasses: [ + 'gl-border-gray-100', + 'gl-border-t-solid', + 'gl-border-1', + 'gl-py-5', + 'gl-pl-7', + 'gl-bg-gray-10', + ], +}; +</script> +<template> + <div> + <div + class="gl-px-3 gl-pt-3 gl-pb-5 gl-display-flex gl-justify-content-space-between gl-align-items-center" + > + <div + :class="{ 'gl-ml-7': inFolder }" + class="gl-min-w-0 gl-mr-4 gl-display-flex gl-align-items-center" + > + <gl-button + class="gl-mr-4 gl-min-w-fit-content" + :icon="icon" + :aria-label="label" + size="small" + category="tertiary" + @click="toggleCollapse" + /> + <gl-link + v-gl-tooltip + :href="environment.environmentPath" + class="gl-text-blue-500 gl-text-truncate" + :class="{ 'gl-font-weight-bold': visible }" + :title="name" + > + {{ displayName }} + </gl-link> + </div> + <div> + <div class="btn-group table-action-buttons" role="group"> + <external-url + v-if="externalUrl" + :external-url="externalUrl" + data-track-action="click_button" + data-track-label="environment_url" + /> + + <actions + v-if="actions.length > 0" + :actions="actions" + data-track-action="click_dropdown" + data-track-label="environment_actions" + graphql + /> + + <stop-component + v-if="canStop" + :environment="environment" + class="gl-z-index-2" + data-track-action="click_button" + data-track-label="environment_stop" + graphql + /> + + <gl-dropdown + v-if="hasExtraActions" + icon="ellipsis_v" + text-sr-only + :text="__('More actions')" + category="secondary" + no-caret + right + > + <rollback + v-if="retryPath" + :environment="environment" + :is-last-deployment="isLastDeployment" + :retry-url="retryPath" + graphql + data-track-action="click_button" + data-track-label="environment_rollback" + /> + + <pin + v-if="canShowAutoStopDate" + :auto-stop-url="autoStopPath" + data-track-action="click_button" + data-track-label="environment_pin" + /> + + <monitoring + v-if="metricsPath" + :monitoring-url="metricsPath" + data-track-action="click_button" + data-track-label="environment_monitoring" + /> + + <terminal + v-if="terminalPath" + :terminal-path="terminalPath" + data-track-action="click_button" + data-track-label="environment_terminal" + /> + + <delete + v-if="canDeleteEnvironment" + :environment="environment" + data-track-action="click_button" + data-track-label="environment_delete" + graphql + /> + </gl-dropdown> + </div> + </div> + </div> + <gl-collapse :visible="visible"> + <div v-if="lastDeployment" :class="$options.deploymentClasses"> + <deployment :deployment="lastDeployment" :class="{ 'gl-ml-7': inFolder }" /> + </div> + <div v-if="upcomingDeployment" :class="$options.deploymentClasses"> + <deployment :deployment="upcomingDeployment" :class="{ 'gl-ml-7': inFolder }" /> + </div> + </gl-collapse> + </div> +</template> diff --git a/app/assets/javascripts/environments/components/new_environments_app.vue b/app/assets/javascripts/environments/components/new_environments_app.vue index 8d94e7021ca..cb36e226d0e 100644 --- a/app/assets/javascripts/environments/components/new_environments_app.vue +++ b/app/assets/javascripts/environments/components/new_environments_app.vue @@ -5,13 +5,24 @@ import { updateHistory, setUrlParams, queryToObject } from '~/lib/utils/url_util import environmentAppQuery from '../graphql/queries/environment_app.query.graphql'; import pollIntervalQuery from '../graphql/queries/poll_interval.query.graphql'; import pageInfoQuery from '../graphql/queries/page_info.query.graphql'; +import environmentToDeleteQuery from '../graphql/queries/environment_to_delete.query.graphql'; +import environmentToRollbackQuery from '../graphql/queries/environment_to_rollback.query.graphql'; +import environmentToStopQuery from '../graphql/queries/environment_to_stop.query.graphql'; import EnvironmentFolder from './new_environment_folder.vue'; import EnableReviewAppModal from './enable_review_app_modal.vue'; +import StopEnvironmentModal from './stop_environment_modal.vue'; +import EnvironmentItem from './new_environment_item.vue'; +import ConfirmRollbackModal from './confirm_rollback_modal.vue'; +import DeleteEnvironmentModal from './delete_environment_modal.vue'; export default { components: { + DeleteEnvironmentModal, + ConfirmRollbackModal, EnvironmentFolder, EnableReviewAppModal, + EnvironmentItem, + StopEnvironmentModal, GlBadge, GlPagination, GlTab, @@ -36,6 +47,15 @@ export default { pageInfo: { query: pageInfoQuery, }, + environmentToDelete: { + query: environmentToDeleteQuery, + }, + environmentToRollback: { + query: environmentToRollbackQuery, + }, + environmentToStop: { + query: environmentToStopQuery, + }, }, inject: ['newEnvironmentPath', 'canCreateEnvironment'], i18n: { @@ -57,6 +77,9 @@ export default { isReviewAppModalVisible: false, page: parseInt(page, 10), scope, + environmentToDelete: {}, + environmentToRollback: {}, + environmentToStop: {}, }; }, computed: { @@ -64,7 +87,10 @@ export default { return this.environmentApp?.reviewApp?.canSetupReviewApp; }, folders() { - return this.environmentApp?.environments.filter((e) => e.size > 1) ?? []; + return this.environmentApp?.environments?.filter((e) => e.size > 1) ?? []; + }, + environments() { + return this.environmentApp?.environments?.filter((e) => e.size === 1) ?? []; }, availableCount() { return this.environmentApp?.availableCount; @@ -119,7 +145,7 @@ export default { }, setScope(scope) { this.scope = scope; - this.resetPolling(); + this.moveToPage(1); }, movePage(direction) { this.moveToPage(this.pageInfo[`${direction}Page`]); @@ -157,6 +183,9 @@ export default { :modal-id="$options.modalId" data-testid="enable-review-app-modal" /> + <delete-environment-modal :environment="environmentToDelete" graphql /> + <stop-environment-modal :environment="environmentToStop" graphql /> + <confirm-rollback-modal :environment="environmentToRollback" graphql /> <gl-tabs :action-secondary="addEnvironment" :action-primary="openReviewAppModal" @@ -187,6 +216,12 @@ export default { class="gl-mb-3" :nested-environment="folder" /> + <environment-item + v-for="environment in environments" + :key="environment.name" + class="gl-mb-3 gl-border-gray-100 gl-border-1 gl-border-b-solid" + :environment="environment.latest" + /> <gl-pagination align="center" :total-items="totalItems" diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue index 7a9233048a9..162ad598c8c 100644 --- a/app/assets/javascripts/environments/components/stop_environment_modal.vue +++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue @@ -2,6 +2,7 @@ import { GlSprintf, GlTooltipDirective, GlModal } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import eventHub from '../event_hub'; +import stopEnvironmentMutation from '../graphql/mutations/stop_environment.mutation.graphql'; export default { id: 'stop-environment-modal', @@ -21,6 +22,11 @@ export default { type: Object, required: true, }, + graphql: { + type: Boolean, + required: false, + default: false, + }, }, computed: { @@ -39,7 +45,14 @@ export default { methods: { onSubmit() { - eventHub.$emit('stopEnvironment', this.environment); + if (this.graphql) { + this.$apollo.mutate({ + mutation: stopEnvironmentMutation, + variables: { environment: this.environment }, + }); + } else { + eventHub.$emit('stopEnvironment', this.environment); + } }, }, }; diff --git a/app/assets/javascripts/environments/graphql/mutations/action.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/action.mutation.graphql new file mode 100644 index 00000000000..bc2c9b33367 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/mutations/action.mutation.graphql @@ -0,0 +1,5 @@ +mutation action($action: LocalAction) { + action(action: $action) @client { + errors + } +} diff --git a/app/assets/javascripts/environments/graphql/mutations/set_environment_to_stop.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/set_environment_to_stop.mutation.graphql new file mode 100644 index 00000000000..2891f4c5101 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/mutations/set_environment_to_stop.mutation.graphql @@ -0,0 +1,3 @@ +mutation SetEnvironmentToStop($environment: LocalEnvironmentInput) { + setEnvironmentToStop(environment: $environment) @client +} diff --git a/app/assets/javascripts/environments/graphql/queries/environment_to_stop.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_to_stop.query.graphql new file mode 100644 index 00000000000..128846145e8 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/environment_to_stop.query.graphql @@ -0,0 +1,3 @@ +query environmentToStop { + environmentToStop @client +} diff --git a/app/assets/javascripts/environments/graphql/queries/is_environment_stopping.query.graphql b/app/assets/javascripts/environments/graphql/queries/is_environment_stopping.query.graphql new file mode 100644 index 00000000000..ad05e252e6f --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/is_environment_stopping.query.graphql @@ -0,0 +1,3 @@ +query isEnvironmentStopping($environment: LocalEnvironment) { + isEnvironmentStopping(environment: $environment) @client +} diff --git a/app/assets/javascripts/environments/graphql/queries/is_last_deployment.query.graphql b/app/assets/javascripts/environments/graphql/queries/is_last_deployment.query.graphql new file mode 100644 index 00000000000..5eda2f18567 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/is_last_deployment.query.graphql @@ -0,0 +1,3 @@ +query isLastDeployment($environment: LocalEnvironment) { + isLastDeployment(environment: $environment) @client +} diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js index 9ebbc0ad1f8..812fa0c81f0 100644 --- a/app/assets/javascripts/environments/graphql/resolvers.js +++ b/app/assets/javascripts/environments/graphql/resolvers.js @@ -8,6 +8,7 @@ import { import pollIntervalQuery from './queries/poll_interval.query.graphql'; import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql'; +import environmentToStopQuery from './queries/environment_to_stop.query.graphql'; import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql'; import pageInfoQuery from './queries/page_info.query.graphql'; @@ -65,8 +66,7 @@ export const resolvers = (endpoint) => ({ })); }, isLastDeployment(_, { environment }) { - // eslint-disable-next-line @gitlab/require-i18n-strings - return environment?.lastDeployment?.['last?']; + return environment?.lastDeployment?.isLast; }, }, Mutation: { @@ -108,6 +108,20 @@ export const resolvers = (endpoint) => ({ ]); }); }, + setEnvironmentToStop(_, { environment }, { client }) { + client.writeQuery({ + query: environmentToStopQuery, + data: { environmentToStop: environment }, + }); + }, + action(_, { action: { playPath } }) { + return axios + .post(playPath) + .then(() => buildErrors()) + .catch(() => + buildErrors([s__('Environments|An error occurred while making the request.')]), + ); + }, setEnvironmentToDelete(_, { environment }, { client }) { client.writeQuery({ query: environmentToDeleteQuery, diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql index 4a3abb0e89f..c02f6b2838a 100644 --- a/app/assets/javascripts/environments/graphql/typedefs.graphql +++ b/app/assets/javascripts/environments/graphql/typedefs.graphql @@ -68,7 +68,9 @@ extend type Query { environmentToDelete: LocalEnvironment pageInfo: LocalPageInfo environmentToRollback: LocalEnvironment - isLastDeployment: Boolean + environmentToStop: LocalEnvironment + isEnvironmentStopping(environment: LocalEnvironmentInput): Boolean + isLastDeployment(environment: LocalEnvironmentInput): Boolean } extend type Mutation { @@ -78,4 +80,6 @@ extend type Mutation { cancelAutoStop(environment: LocalEnvironmentInput): LocalErrors setEnvironmentToDelete(environment: LocalEnvironmentInput): LocalErrors setEnvironmentToRollback(environment: LocalEnvironmentInput): LocalErrors + setEnvironmentToStop(environment: LocalEnvironmentInput): LocalErrors + action(environment: LocalEnvironmentInput): LocalErrors } diff --git a/app/assets/javascripts/experimental_flags.js b/app/assets/javascripts/experimental_flags.js deleted file mode 100644 index 1d60847147b..00000000000 --- a/app/assets/javascripts/experimental_flags.js +++ /dev/null @@ -1,15 +0,0 @@ -import $ from 'jquery'; -import Cookies from 'js-cookie'; - -export default () => { - $('.js-experiment-feature-toggle').on('change', (e) => { - const el = e.target; - - Cookies.set(el.name, el.value, { - expires: 365 * 10, - }); - - document.body.scrollTop = 0; - window.location.reload(); - }); -}; diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index f0ef55f73eb..d9c2e55cffe 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -1,5 +1,8 @@ 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 = { @@ -9,6 +12,12 @@ const FLASH_TYPES = { WARNING: 'warning', }; +const VARIANT_SUCCESS = 'success'; +const VARIANT_WARNING = 'warning'; +const VARIANT_DANGER = 'danger'; +const VARIANT_INFO = 'info'; +const VARIANT_TIP = 'tip'; + const FLASH_CLOSED_EVENT = 'flashClosed'; const getCloseEl = (flashEl) => { @@ -68,6 +77,126 @@ const addDismissFlashClickListener = (flashEl, fadeTransition) => { getCloseEl(flashEl)?.addEventListener('click', () => hideFlash(flashEl, fadeTransition)); }; +/** + * Render an alert at the top of the page, or, optionally an + * arbitrary existing container. + * + * This alert is always dismissible. + * + * Usage: + * + * 1. Render a new alert + * + * import { createAlert, ALERT_VARIANTS } from '~/flash'; + * + * createAlert({ message: 'My error message' }); + * createAlert({ message: 'My warning message', variant: ALERT_VARIANTS.WARNING }); + * + * 2. Dismiss this alert programmatically + * + * const alert = createAlert({ message: 'Message' }); + * + * // ... + * + * alert.dismiss(); + * + * 3. Respond to the alert being dismissed + * + * createAlert({ message: 'Message', onDismiss: () => { ... }}); + * + * @param {Object} options Options to control the flash message + * @param {String} options.message Alert message text + * @param {String?} options.variant Which GlAlert variant to use, should be VARIANT_SUCCESS, VARIANT_WARNING, VARIANT_DANGER, VARIANT_INFO or VARIANT_TIP. Defaults to VARIANT_DANGER. + * @param {Object?} options.parent Reference to parent element under which alert needs to appear. Defaults to `document`. + * @param {Function?} options.onDismiss Handler to call when this alert is dismissed. + * @param {Object?} options.containerSelector Selector for the container of the alert + * @param {Object?} options.primaryButton Object describing primary button of alert + * @param {String?} link Href of primary button + * @param {String?} text Text of primary button + * @param {Function?} clickHandler Handler to call when primary button is clicked on. The click event is sent as an argument. + * @param {Object?} options.secondaryButton Object describing secondary button of alert + * @param {String?} link Href of secondary button + * @param {String?} text Text of secondary button + * @param {Function?} clickHandler Handler to call when secondary button is clicked on. The click event is sent as an argument. + * @param {Boolean?} options.captureError Whether to send error to Sentry + * @param {Object} options.error Error to be captured in Sentry + * @returns + */ +const createAlert = function createAlert({ + message, + variant = VARIANT_DANGER, + parent = document, + containerSelector = '.flash-container', + primaryButton = null, + secondaryButton = null, + onDismiss = null, + captureError = false, + error = null, +}) { + if (captureError && error) Sentry.captureException(error); + + const alertContainer = parent.querySelector(containerSelector); + if (!alertContainer) return null; + + const el = document.createElement('div'); + alertContainer.appendChild(el); + + return new Vue({ + el, + components: { + GlAlert, + }, + methods: { + /** + * Public method to dismiss this alert and removes + * this Vue instance. + */ + dismiss() { + if (onDismiss) { + onDismiss(); + } + this.$destroy(); + this.$el.parentNode.removeChild(this.$el); + }, + }, + render(h) { + const on = {}; + + on.dismiss = () => { + this.dismiss(); + }; + + if (primaryButton?.clickHandler) { + on.primaryAction = (e) => { + primaryButton.clickHandler(e); + }; + } + if (secondaryButton?.clickHandler) { + on.secondaryAction = (e) => { + secondaryButton.clickHandler(e); + }; + } + + return h( + GlAlert, + { + props: { + dismissible: true, + dismissLabel: __('Dismiss'), + variant, + primaryButtonLink: primaryButton?.link, + primaryButtonText: primaryButton?.text, + secondaryButtonLink: secondaryButton?.link, + secondaryButtonText: secondaryButton?.text, + }, + on, + }, + message, + ); + }, + }); +}; + /* * Flash banner supports different types of Flash configurations * along with ability to provide actionConfig which can be used to show @@ -82,8 +211,8 @@ const addDismissFlashClickListener = (flashEl, fadeTransition) => { * @param {String} title Title of action * @param {Function} 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.captureError Boolean to determine whether to send error to sentry - * @param {Object} options.error Error to be captured in sentry + * @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, @@ -134,4 +263,10 @@ export { addDismissFlashClickListener, FLASH_TYPES, FLASH_CLOSED_EVENT, + createAlert, + VARIANT_SUCCESS, + VARIANT_WARNING, + VARIANT_DANGER, + VARIANT_INFO, + VARIANT_TIP, }; diff --git a/app/assets/javascripts/gitlab_version_check.js b/app/assets/javascripts/gitlab_version_check.js new file mode 100644 index 00000000000..2892aded7c5 --- /dev/null +++ b/app/assets/javascripts/gitlab_version_check.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import GitlabVersionCheck from '~/vue_shared/components/gitlab_version_check.vue'; + +const mountGitlabVersionCheck = (el) => { + const { size } = el.dataset; + + return new Vue({ + el, + render(createElement) { + return createElement(GitlabVersionCheck, { + props: { + size, + }, + }); + }, + }); +}; + +export default () => + [...document.querySelectorAll('.js-gitlab-version-check')].map(mountGitlabVersionCheck); diff --git a/app/assets/javascripts/google_cloud/components/deployments_service_table.vue b/app/assets/javascripts/google_cloud/components/deployments_service_table.vue new file mode 100644 index 00000000000..7d27d7cf6b2 --- /dev/null +++ b/app/assets/javascripts/google_cloud/components/deployments_service_table.vue @@ -0,0 +1,61 @@ +<script> +import { GlButton, GlTable } from '@gitlab/ui'; +import { __ } from '~/locale'; + +const i18n = { + cloudRun: __('Cloud Run'), + cloudRunDescription: __('Deploy container based web apps on Google managed clusters'), + cloudStorage: __('Cloud Storage'), + cloudStorageDescription: __('Deploy static assets and resources to Google managed CDN'), + deployments: __('Deployments'), + deploymentsDescription: __( + 'Configure pipelines to deploy web apps, backend services, APIs and static resources to Google Cloud', + ), + configureViaMergeRequest: __('Configure via Merge Request'), + service: __('Service'), + description: __('Description'), +}; + +export default { + components: { GlButton, GlTable }, + props: { + cloudRunUrl: { + type: String, + required: true, + }, + cloudStorageUrl: { + type: String, + required: true, + }, + }, + fields: [ + { key: 'title', label: i18n.service }, + { key: 'description', label: i18n.description }, + { key: 'action', label: '' }, + ], + items: [ + { + title: i18n.cloudRun, + description: i18n.cloudRunDescription, + action: { title: i18n.configureViaMergeRequest, disabled: true }, + }, + { + title: i18n.cloudStorage, + description: i18n.cloudStorageDescription, + action: { title: i18n.configureViaMergeRequest, disabled: true }, + }, + ], + i18n, +}; +</script> +<template> + <div class="gl-mx-3"> + <h2 class="gl-font-size-h2">{{ $options.i18n.deployments }}</h2> + <p>{{ $options.i18n.deploymentsDescription }}</p> + <gl-table :fields="$options.fields" :items="$options.items"> + <template #cell(action)="{ value }"> + <gl-button :disabled="value.disabled">{{ value.title }}</gl-button> + </template> + </gl-table> + </div> +</template> diff --git a/app/assets/javascripts/google_cloud/components/home.vue b/app/assets/javascripts/google_cloud/components/home.vue index 05f39de66ee..8ef110dcf22 100644 --- a/app/assets/javascripts/google_cloud/components/home.vue +++ b/app/assets/javascripts/google_cloud/components/home.vue @@ -1,11 +1,13 @@ <script> import { GlTabs, GlTab } from '@gitlab/ui'; +import DeploymentsServiceTable from './deployments_service_table.vue'; import ServiceAccountsList from './service_accounts_list.vue'; export default { components: { GlTabs, GlTab, + DeploymentsServiceTable, ServiceAccountsList, }, props: { @@ -21,6 +23,14 @@ export default { type: String, required: true, }, + deploymentsCloudRunUrl: { + type: String, + required: true, + }, + deploymentsCloudStorageUrl: { + type: String, + required: true, + }, }, }; </script> @@ -35,7 +45,12 @@ export default { :empty-illustration-url="emptyIllustrationUrl" /> </gl-tab> - <gl-tab :title="__('Deployments')" disabled /> + <gl-tab :title="__('Deployments')"> + <deployments-service-table + :cloud-run-url="deploymentsCloudRunUrl" + :cloud-storage-url="deploymentsCloudStorageUrl" + /> + </gl-tab> <gl-tab :title="__('Services')" disabled /> </gl-tabs> </template> diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js new file mode 100644 index 00000000000..ab80e15c2ec --- /dev/null +++ b/app/assets/javascripts/google_tag_manager/index.js @@ -0,0 +1,122 @@ +import { logError } from '~/lib/logger'; + +const isSupported = () => Boolean(window.dataLayer) && gon.features?.gitlabGtmDatalayer; + +const pushEvent = (event, args = {}) => { + if (!window.dataLayer) { + return; + } + + try { + window.dataLayer.push({ + event, + ...args, + }); + } catch (e) { + logError('Unexpected error while pushing to dataLayer', e); + } +}; + +const pushAccountSubmit = (accountType, accountMethod) => + pushEvent('accountSubmit', { accountType, accountMethod }); + +const trackFormSubmission = (accountType) => { + const form = document.getElementById('new_new_user'); + form.addEventListener('submit', () => { + pushAccountSubmit(accountType, 'form'); + }); +}; + +const trackOmniAuthSubmission = (accountType) => { + const links = document.querySelectorAll('.js-oauth-login'); + links.forEach((link) => { + const { provider } = link.dataset; + link.addEventListener('click', () => { + pushAccountSubmit(accountType, provider); + }); + }); +}; + +export const trackFreeTrialAccountSubmissions = () => { + if (!isSupported()) { + return; + } + + trackFormSubmission('freeThirtyDayTrial'); + trackOmniAuthSubmission('freeThirtyDayTrial'); +}; + +export const trackNewRegistrations = () => { + if (!isSupported()) { + return; + } + + trackFormSubmission('standardSignUp'); + trackOmniAuthSubmission('standardSignUp'); +}; + +export const trackSaasTrialSubmit = () => { + if (!isSupported()) { + return; + } + + pushEvent('saasTrialSubmit'); +}; + +export const trackSaasTrialSkip = () => { + if (!isSupported()) { + return; + } + + const skipLink = document.querySelector('.js-skip-trial'); + skipLink.addEventListener('click', () => { + pushEvent('saasTrialSkip'); + }); +}; + +export const trackSaasTrialGroup = () => { + if (!isSupported()) { + return; + } + + const form = document.querySelector('.js-saas-trial-group'); + form.addEventListener('submit', () => { + pushEvent('saasTrialGroup'); + }); +}; + +export const trackSaasTrialProject = () => { + if (!isSupported()) { + return; + } + + const form = document.getElementById('new_project'); + form.addEventListener('submit', () => { + pushEvent('saasTrialProject'); + }); +}; + +export const trackSaasTrialProjectImport = () => { + if (!isSupported()) { + return; + } + + const importButtons = document.querySelectorAll('.js-import-project-btn'); + importButtons.forEach((button) => { + button.addEventListener('click', () => { + const { platform } = button.dataset; + pushEvent('saasTrialProjectImport', { saasProjectImport: platform }); + }); + }); +}; + +export const trackSaasTrialGetStarted = () => { + if (!isSupported()) { + return; + } + + const getStartedButton = document.querySelector('.js-get-started-btn'); + getStartedButton.addEventListener('click', () => { + pushEvent('saasTrialGetStarted'); + }); +}; diff --git a/app/assets/javascripts/graphql_shared/fragment_types/vulnerability_location_types.js b/app/assets/javascripts/graphql_shared/fragment_types/vulnerability_location_types.js new file mode 100644 index 00000000000..30888e20a46 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/fragment_types/vulnerability_location_types.js @@ -0,0 +1,17 @@ +export const vulnerabilityLocationTypes = { + __schema: { + types: [ + { + kind: 'UNION', + name: 'VulnerabilityLocation', + possibleTypes: [ + { name: 'VulnerabilityLocationContainerScanning' }, + { name: 'VulnerabilityLocationDast' }, + { name: 'VulnerabilityLocationDependencyScanning' }, + { name: 'VulnerabilityLocationSast' }, + { name: 'VulnerabilityLocationSecretDetection' }, + ], + }, + ], + }, +}; diff --git a/app/assets/javascripts/graphql_shared/fragments/issue.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/issue.fragment.graphql new file mode 100644 index 00000000000..85a28fe1f71 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/fragments/issue.fragment.graphql @@ -0,0 +1,37 @@ +#import "~/graphql_shared/fragments/milestone.fragment.graphql" +#import "~/graphql_shared/fragments/user.fragment.graphql" + +fragment IssueNode on Issue { + id + iid + title + referencePath: reference(full: true) + dueDate + timeEstimate + totalTimeSpent + humanTimeEstimate + humanTotalTimeSpent + emailsDisabled + confidential + hidden + webUrl + relativePosition + type + severity + milestone { + ...MilestoneFragment + } + assignees { + nodes { + ...User + } + } + labels { + nodes { + id + title + color + description + } + } +} diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js index f255f8a084c..b6a6720e7a1 100644 --- a/app/assets/javascripts/group.js +++ b/app/assets/javascripts/group.js @@ -13,11 +13,8 @@ export default class Group { this.updateGroupPathSlugHandler = this.updateGroupPathSlug.bind(this); this.groupNames.forEach((groupName) => { - if (groupName.value === '') { - groupName.addEventListener('keyup', this.updateHandler); - - groupName.addEventListener('keyup', this.updateGroupPathSlugHandler); - } + groupName.addEventListener('keyup', this.updateHandler); + groupName.addEventListener('keyup', this.updateGroupPathSlugHandler); }); this.groupPaths.forEach((groupPath) => { diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue index 46e9d2bec99..c24eeed9f03 100644 --- a/app/assets/javascripts/groups/components/item_stats.vue +++ b/app/assets/javascripts/groups/components/item_stats.vue @@ -83,7 +83,7 @@ export default { <gl-badge variant="warning">{{ __('pending deletion') }}</gl-badge> </div> <div v-if="isProject" class="last-updated"> - <time-ago-tooltip :time="item.updatedAt" tooltip-placement="bottom" /> + <time-ago-tooltip :time="item.lastActivityAt" tooltip-placement="bottom" /> </div> </div> </template> diff --git a/app/assets/javascripts/groups_list.js b/app/assets/javascripts/groups/groups_list.js index 56a8cbf6d03..866dd7a61ff 100644 --- a/app/assets/javascripts/groups_list.js +++ b/app/assets/javascripts/groups/groups_list.js @@ -1,4 +1,4 @@ -import FilterableList from './filterable_list'; +import FilterableList from '~/filterable_list'; /** * Makes search request for groups when user types a value in the search input. diff --git a/app/assets/javascripts/landing.js b/app/assets/javascripts/groups/landing.js index bfb4d9ce67b..bfb4d9ce67b 100644 --- a/app/assets/javascripts/landing.js +++ b/app/assets/javascripts/groups/landing.js diff --git a/app/assets/javascripts/groups/store/groups_store.js b/app/assets/javascripts/groups/store/groups_store.js index 93fbd8be47d..d3600bd223a 100644 --- a/app/assets/javascripts/groups/store/groups_store.js +++ b/app/assets/javascripts/groups/store/groups_store.js @@ -98,6 +98,9 @@ export default class GroupsStore { updatedAt: rawGroupItem.updated_at, pendingRemoval: rawGroupItem.marked_for_deletion, microdata: this.showSchemaMarkup ? getGroupItemMicrodata(rawGroupItem) : {}, + lastActivityAt: rawGroupItem.last_activity_at + ? rawGroupItem.last_activity_at + : rawGroupItem.updated_at, }; if (!isEmpty(rawGroupItem.compliance_management_framework)) { diff --git a/app/assets/javascripts/transfer_edit.js b/app/assets/javascripts/groups/transfer_edit.js index bb15e11fd4c..bb15e11fd4c 100644 --- a/app/assets/javascripts/transfer_edit.js +++ b/app/assets/javascripts/groups/transfer_edit.js diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue index edc6573a489..36fc48a2ba8 100644 --- a/app/assets/javascripts/header_search/components/app.vue +++ b/app/assets/javascripts/header_search/components/app.vue @@ -19,8 +19,7 @@ import HeaderSearchScopedItems from './header_search_scoped_items.vue'; export default { name: 'HeaderSearchApp', i18n: { - searchPlaceholder: s__('GlobalSearch|Search or jump to...'), - searchAria: s__('GlobalSearch|Search GitLab'), + searchGitlab: s__('GlobalSearch|Search GitLab'), searchInputDescribeByNoDropdown: s__( 'GlobalSearch|Type and press the enter key to submit search.', ), @@ -136,7 +135,7 @@ export default { <form v-outside="closeDropdown" role="search" - :aria-label="$options.i18n.searchAria" + :aria-label="$options.i18n.searchGitlab" class="header-search gl-relative" > <gl-search-box-by-type @@ -145,7 +144,7 @@ export default { role="searchbox" class="gl-z-index-1" autocomplete="off" - :placeholder="$options.i18n.searchPlaceholder" + :placeholder="$options.i18n.searchGitlab" :aria-activedescendant="currentFocusedId" :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" @focus="openDropdown" diff --git a/app/assets/javascripts/helpers/event_hub_factory.js b/app/assets/javascripts/helpers/event_hub_factory.js index 62af67d3ef3..ddfae7e9de3 100644 --- a/app/assets/javascripts/helpers/event_hub_factory.js +++ b/app/assets/javascripts/helpers/event_hub_factory.js @@ -1,16 +1,12 @@ /** * An event hub with a Vue instance like API * - * NOTE: There's an [issue open][4] to eventually remove this when some - * coupling in our codebase has been fixed. - * * NOTE: This is a derivative work from [mitt][1] v1.2.0 which is licensed by * [MIT License][2] © [Jason Miller][3] * * [1]: https://github.com/developit/mitt * [2]: https://opensource.org/licenses/MIT * [3]: https://jasonformat.com/ - * [4]: https://gitlab.com/gitlab-org/gitlab/-/issues/223864 */ class EventHub { constructor() { @@ -91,9 +87,6 @@ class EventHub { * - $once * - $emit * - * Please note, this was once implemented with `mitt`, but since then has been reverted - * because of some API issues. https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35074 - * * We'd like to shy away from using a full fledged Vue instance from this in the future. */ export default () => { diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue index 938385f0b81..796ca1349c5 100644 --- a/app/assets/javascripts/ide/components/jobs/stage.vue +++ b/app/assets/javascripts/ide/components/jobs/stage.vue @@ -1,5 +1,5 @@ <script> -import { GlLoadingIcon, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlLoadingIcon, GlIcon, GlTooltipDirective, GlBadge } from '@gitlab/ui'; import CiIcon from '../../../vue_shared/components/ci_icon.vue'; import Item from './item.vue'; @@ -9,6 +9,7 @@ export default { }, components: { GlIcon, + GlBadge, CiIcon, Item, GlLoadingIcon, @@ -74,7 +75,7 @@ export default { {{ stage.name }} </strong> <div v-if="!stage.isLoading || stage.jobs.length" class="gl-mr-3 gl-ml-2"> - <span class="badge badge-pill"> {{ jobsCount }} </span> + <gl-badge>{{ jobsCount }}</gl-badge> </div> <gl-icon :name="collapseIcon" class="ide-stage-collapse-icon" /> </div> diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js index 9cf8d5a360e..51872993f16 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js @@ -53,9 +53,15 @@ export const receiveLatestPipelineSuccess = ({ rootGetters, commit }, { pipeline commit(types.RECEIVE_LASTEST_PIPELINE_SUCCESS, lastCommitPipeline); }; -export const fetchLatestPipeline = ({ dispatch, rootGetters }) => { +export const fetchLatestPipeline = ({ commit, dispatch, rootGetters }) => { if (eTagPoll) return; + if (!rootGetters.lastCommit) { + commit(types.RECEIVE_LASTEST_PIPELINE_SUCCESS, null); + dispatch('stopPipelinePolling'); + return; + } + dispatch('requestLatestPipeline'); eTagPoll = new Poll({ diff --git a/app/assets/javascripts/init_confirm_danger.js b/app/assets/javascripts/init_confirm_danger.js index a8833a17467..98bfa48740c 100644 --- a/app/assets/javascripts/init_confirm_danger.js +++ b/app/assets/javascripts/init_confirm_danger.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { pickBy } from 'lodash'; import { parseBoolean } from './lib/utils/common_utils'; import ConfirmDanger from './vue_shared/components/confirm_danger/confirm_danger.vue'; @@ -12,21 +13,32 @@ export default () => { buttonText, buttonClass = '', buttonTestid = null, + buttonVariant = null, confirmDangerMessage, + confirmButtonText = null, disabled = false, + additionalInformation, + htmlConfirmationMessage, } = el.dataset; return new Vue({ el, - provide: { - confirmDangerMessage, - }, + provide: pickBy( + { + htmlConfirmationMessage, + confirmDangerMessage, + additionalInformation, + confirmButtonText, + }, + (v) => Boolean(v), + ), render: (createElement) => createElement(ConfirmDanger, { props: { phrase, buttonText, buttonClass, + buttonVariant, buttonTestid, disabled: parseBoolean(disabled), }, diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js index 84656bd41bb..b90658fb13c 100644 --- a/app/assets/javascripts/integrations/constants.js +++ b/app/assets/javascripts/integrations/constants.js @@ -23,3 +23,8 @@ export const I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE = s__( ); export const I18N_DEFAULT_ERROR_MESSAGE = __('Something went wrong on our end.'); export const I18N_SUCCESSFUL_CONNECTION_MESSAGE = s__('Integrations|Connection successful.'); + +export const settingsTabTitle = __('Settings'); +export const overridesTabTitle = s__('Integrations|Projects using custom settings'); + +export const INTEGRATION_FORM_SELECTOR = '.js-integration-settings-form'; diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue index 258cd1bf365..4b0579a5beb 100644 --- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue +++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue @@ -153,7 +153,7 @@ export default { :invalid-feedback="__('This field is required.')" :state="valid" > - <template #description> + <template v-if="!isCheckbox" #description> <span v-safe-html:[$options.helpHtmlConfig]="help"></span> </template> @@ -161,6 +161,9 @@ export default { <input :name="fieldName" type="hidden" :value="model || false" /> <gl-form-checkbox :id="fieldId" v-model="model" :disabled="isInheriting"> {{ checkboxLabel || humanizedTitle }} + <template #help> + <span v-safe-html:[$options.helpHtmlConfig]="help"></span> + </template> </gl-form-checkbox> </template> <template v-else-if="isSelect"> diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index e570a468944..c3cc35adfa5 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -1,5 +1,6 @@ <script> -import { GlButton, GlModalDirective, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlButton, GlModalDirective, GlSafeHtmlDirective as SafeHtml, GlForm } from '@gitlab/ui'; +import axios from 'axios'; import * as Sentry from '@sentry/browser'; import { mapState, mapActions, mapGetters } from 'vuex'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -8,8 +9,11 @@ import { I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE, I18N_DEFAULT_ERROR_MESSAGE, I18N_SUCCESSFUL_CONNECTION_MESSAGE, + INTEGRATION_FORM_SELECTOR, integrationLevels, } from '~/integrations/constants'; +import { refreshCurrentPage } from '~/lib/utils/url_utility'; +import csrf from '~/lib/utils/csrf'; import eventHub from '../event_hub'; import { testIntegrationSettings } from '../api'; import ActiveCheckbox from './active_checkbox.vue'; @@ -33,6 +37,7 @@ export default { ConfirmationModal, ResetConfirmationModal, GlButton, + GlForm, }, directives: { GlModal: GlModalDirective, @@ -40,10 +45,6 @@ export default { }, mixins: [glFeatureFlagsMixin()], props: { - formSelector: { - type: String, - required: true, - }, helpHtml: { type: String, required: false, @@ -55,11 +56,12 @@ export default { integrationActive: false, isTesting: false, isSaving: false, + isResetting: false, }; }, computed: { ...mapGetters(['currentKey', 'propsSource']), - ...mapState(['defaultState', 'customState', 'override', 'isResetting']), + ...mapState(['defaultState', 'customState', 'override']), isEditable() { return this.propsSource.editable; }, @@ -81,10 +83,28 @@ export default { disableButtons() { return Boolean(this.isSaving || this.isResetting || this.isTesting); }, + useVueForm() { + return this.glFeatures?.vueIntegrationForm; + }, + formContainerProps() { + return this.useVueForm + ? { + ref: 'integrationForm', + method: 'post', + class: 'gl-mb-3 gl-show-field-errors integration-settings-form', + action: this.propsSource.formPath, + novalidate: !this.integrationActive, + } + : {}; + }, + formContainer() { + return this.useVueForm ? GlForm : 'div'; + }, }, mounted() { - // this form element is defined in Haml - this.form = document.querySelector(this.formSelector); + this.form = this.useVueForm + ? this.$refs.integrationForm.$el + : document.querySelector(INTEGRATION_FORM_SELECTOR); }, methods: { ...mapActions(['setOverride', 'fetchResetIntegration', 'requestJiraIssueTypes']), @@ -126,7 +146,20 @@ export default { }); }, onResetClick() { - this.fetchResetIntegration(); + this.isResetting = true; + + return axios + .post(this.propsSource.resetPath) + .then(() => { + refreshCurrentPage(); + }) + .catch((error) => { + this.$toast.show(I18N_DEFAULT_ERROR_MESSAGE); + Sentry.captureException(error); + }) + .finally(() => { + this.isResetting = false; + }); }, onRequestJiraIssueTypes() { this.requestJiraIssueTypes(this.getFormData()); @@ -136,7 +169,7 @@ export default { }, onToggleIntegrationState(integrationActive) { this.integrationActive = integrationActive; - if (!this.form) { + if (!this.form || this.useVueForm) { return; } @@ -153,11 +186,23 @@ export default { 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, }; </script> <template> - <div class="gl-mb-3"> + <component :is="formContainer" v-bind="formContainerProps"> + <template v-if="useVueForm"> + <input type="hidden" name="_method" value="put" /> + <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> + <input + type="hidden" + name="redirect_to" + :value="propsSource.redirectTo" + data-testid="redirect-to-field" + /> + </template> + <override-dropdown v-if="defaultState !== null" :inherit-from-id="defaultState.id" @@ -200,63 +245,71 @@ export default { v-bind="propsSource.jiraIssuesProps" @request-jira-issue-types="onRequestJiraIssueTypes" /> - <div v-if="isEditable" class="footer-block row-content-block"> - <template v-if="isInstanceOrGroupLevel"> + + <div + v-if="isEditable" + class="footer-block row-content-block gl-display-flex gl-justify-content-space-between" + > + <div> + <template v-if="isInstanceOrGroupLevel"> + <gl-button + v-gl-modal.confirmSaveIntegration + category="primary" + variant="confirm" + :loading="isSaving" + :disabled="disableButtons" + data-testid="save-button-instance-group" + data-qa-selector="save_changes_button" + > + {{ __('Save changes') }} + </gl-button> + <confirmation-modal @submit="onSaveClick" /> + </template> <gl-button - v-gl-modal.confirmSaveIntegration + v-else category="primary" variant="confirm" + type="submit" :loading="isSaving" :disabled="disableButtons" + data-testid="save-button" data-qa-selector="save_changes_button" + @click.prevent="onSaveClick" > {{ __('Save changes') }} </gl-button> - <confirmation-modal @submit="onSaveClick" /> - </template> - <gl-button - v-else - category="primary" - variant="confirm" - type="submit" - :loading="isSaving" - :disabled="disableButtons" - data-testid="save-button" - data-qa-selector="save_changes_button" - @click.prevent="onSaveClick" - > - {{ __('Save changes') }} - </gl-button> - <gl-button - v-if="showTestButton" - category="secondary" - variant="confirm" - :loading="isTesting" - :disabled="disableButtons" - data-testid="test-button" - @click.prevent="onTestClick" - > - {{ __('Test settings') }} - </gl-button> + <gl-button + v-if="showTestButton" + category="secondary" + variant="confirm" + :loading="isTesting" + :disabled="disableButtons" + data-testid="test-button" + @click.prevent="onTestClick" + > + {{ __('Test settings') }} + </gl-button> + + <gl-button :href="propsSource.cancelPath">{{ __('Cancel') }}</gl-button> + </div> <template v-if="showResetButton"> <gl-button v-gl-modal.confirmResetIntegration - category="secondary" - variant="confirm" + category="tertiary" + variant="danger" :loading="isResetting" :disabled="disableButtons" data-testid="reset-button" > {{ __('Reset') }} </gl-button> + <reset-confirmation-modal @reset="onResetClick" /> </template> - - <gl-button :href="propsSource.cancelPath">{{ __('Cancel') }}</gl-button> </div> </div> </div> - </div> + </component> </template> diff --git a/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue b/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue index 5a445235219..403bad3db11 100644 --- a/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue +++ b/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue @@ -11,7 +11,7 @@ export default { primaryProps() { return { text: __('Reset'), - attributes: [{ variant: 'warning' }, { category: 'primary' }], + attributes: [{ variant: 'danger' }, { category: 'primary' }], }; }, cancelProps() { diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js index 9c9e3edbeb8..fbda8c1e3d0 100644 --- a/app/assets/javascripts/integrations/edit/index.js +++ b/app/assets/javascripts/integrations/edit/index.js @@ -28,9 +28,11 @@ function parseDatasetToProps(data) { cancelPath, testPath, resetPath, + formPath, vulnerabilitiesIssuetype, jiraIssueTransitionAutomatic, jiraIssueTransitionId, + redirectTo, ...booleanAttributes } = data; const { @@ -57,6 +59,7 @@ function parseDatasetToProps(data) { canTest, testPath, resetPath, + formPath, triggerFieldsProps: { initialTriggerCommit: commitEvents, initialTriggerMergeRequest: mergeRequestEvents, @@ -82,10 +85,11 @@ function parseDatasetToProps(data) { inheritFromId: parseInt(inheritFromId, 10), integrationLevel, id: parseInt(id, 10), + redirectTo, }; } -export default function initIntegrationSettingsForm(formSelector) { +export default function initIntegrationSettingsForm() { const customSettingsEl = document.querySelector('.js-vue-integration-settings'); const defaultSettingsEl = document.querySelector('.js-vue-default-integration-settings'); @@ -115,7 +119,6 @@ export default function initIntegrationSettingsForm(formSelector) { return createElement(IntegrationForm, { props: { helpHtml, - formSelector, }, }); }, diff --git a/app/assets/javascripts/integrations/edit/store/actions.js b/app/assets/javascripts/integrations/edit/store/actions.js index 97565a3a69c..1398b710d1d 100644 --- a/app/assets/javascripts/integrations/edit/store/actions.js +++ b/app/assets/javascripts/integrations/edit/store/actions.js @@ -1,5 +1,3 @@ -import axios from 'axios'; -import { refreshCurrentPage } from '~/lib/utils/url_utility'; import { VALIDATE_INTEGRATION_FORM_EVENT, I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE, @@ -10,27 +8,6 @@ import eventHub from '../event_hub'; import * as types from './mutation_types'; export const setOverride = ({ commit }, override) => commit(types.SET_OVERRIDE, override); -export const setIsResetting = ({ commit }, isResetting) => - commit(types.SET_IS_RESETTING, isResetting); - -export const requestResetIntegration = ({ commit }) => { - commit(types.REQUEST_RESET_INTEGRATION); -}; -export const receiveResetIntegrationSuccess = () => { - refreshCurrentPage(); -}; -export const receiveResetIntegrationError = ({ commit }) => { - commit(types.RECEIVE_RESET_INTEGRATION_ERROR); -}; - -export const fetchResetIntegration = ({ dispatch, getters }) => { - dispatch('requestResetIntegration'); - - return axios - .post(getters.propsSource.resetPath, { params: { format: 'json' } }) - .then(() => dispatch('receiveResetIntegrationSuccess')) - .catch(() => dispatch('receiveResetIntegrationError')); -}; export const requestJiraIssueTypes = ({ commit, dispatch, getters }, formData) => { commit(types.SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE, ''); diff --git a/app/assets/javascripts/integrations/edit/store/mutations.js b/app/assets/javascripts/integrations/edit/store/mutations.js index e7e312ce650..6ca644f8821 100644 --- a/app/assets/javascripts/integrations/edit/store/mutations.js +++ b/app/assets/javascripts/integrations/edit/store/mutations.js @@ -4,15 +4,6 @@ export default { [types.SET_OVERRIDE](state, override) { state.override = override; }, - [types.SET_IS_RESETTING](state, isResetting) { - state.isResetting = isResetting; - }, - [types.REQUEST_RESET_INTEGRATION](state) { - state.isResetting = true; - }, - [types.RECEIVE_RESET_INTEGRATION_ERROR](state) { - state.isResetting = false; - }, [types.SET_JIRA_ISSUE_TYPES](state, jiraIssueTypes) { state.jiraIssueTypes = jiraIssueTypes; }, diff --git a/app/assets/javascripts/integrations/edit/store/state.js b/app/assets/javascripts/integrations/edit/store/state.js index 3d40d1b90d5..088476b2b37 100644 --- a/app/assets/javascripts/integrations/edit/store/state.js +++ b/app/assets/javascripts/integrations/edit/store/state.js @@ -5,8 +5,6 @@ export default ({ defaultState = null, customState = {} } = {}) => { override, defaultState, customState, - isSaving: false, - isResetting: false, isLoadingJiraIssueTypes: false, loadingJiraIssueTypesErrorMessage: '', jiraIssueTypes: [], diff --git a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue index 3fc554c5371..f2d3e6489ee 100644 --- a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue +++ b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue @@ -11,6 +11,8 @@ import { __, s__ } from '~/locale'; import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue'; +import IntegrationTabs from './integration_tabs.vue'; + const DEFAULT_PAGE = 1; export default { @@ -23,6 +25,7 @@ export default { GlAlert, ProjectAvatar, UrlSync, + IntegrationTabs, }, props: { overridesPath: { @@ -46,6 +49,9 @@ export default { }; }, computed: { + overridesCount() { + return this.isLoading ? null : this.totalItems; + }, showPagination() { return this.totalItems > this.$options.DEFAULT_PER_PAGE && this.overrides.length > 0; }, @@ -100,6 +106,7 @@ export default { <template> <div> + <integration-tabs :project-overrides-count="overridesCount" /> <gl-alert v-if="errorMessage" variant="danger" :dismissible="false"> {{ errorMessage }} </gl-alert> diff --git a/app/assets/javascripts/integrations/overrides/components/integration_tabs.vue b/app/assets/javascripts/integrations/overrides/components/integration_tabs.vue new file mode 100644 index 00000000000..3f67c987231 --- /dev/null +++ b/app/assets/javascripts/integrations/overrides/components/integration_tabs.vue @@ -0,0 +1,52 @@ +<script> +import { GlBadge, GlNavItem, GlTabs, GlTab } from '@gitlab/ui'; +import { settingsTabTitle, overridesTabTitle } from '~/integrations/constants'; + +export default { + components: { + GlBadge, + GlNavItem, + GlTabs, + GlTab, + }, + inject: { + editPath: { + default: '', + }, + }, + props: { + projectOverridesCount: { + type: [Number, String], + required: false, + default: null, + }, + }, + i18n: { + settingsTabTitle, + overridesTabTitle, + }, +}; +</script> + +<template> + <gl-tabs> + <template #tabs-start> + <gl-nav-item role="presentation" link-classes="gl-tab-nav-item" :href="editPath">{{ + $options.i18n.settingsTabTitle + }}</gl-nav-item> + </template> + + <gl-tab active> + <template #title> + {{ $options.i18n.overridesTabTitle }} + <gl-badge + v-if="projectOverridesCount !== null" + variant="muted" + size="sm" + class="gl-tab-counter-badge" + >{{ projectOverridesCount }}</gl-badge + > + </template> + </gl-tab> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/integrations/overrides/index.js b/app/assets/javascripts/integrations/overrides/index.js index 0f03b23ba21..f289a2d3d1a 100644 --- a/app/assets/javascripts/integrations/overrides/index.js +++ b/app/assets/javascripts/integrations/overrides/index.js @@ -8,10 +8,13 @@ export default () => { return null; } - const { overridesPath } = el.dataset; + const { editPath, overridesPath } = el.dataset; return new Vue({ el, + provide: { + editPath, + }, render(createElement) { return createElement(IntegrationOverrides, { props: { diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/index.js b/app/assets/javascripts/issuable/bulk_update_sidebar/index.js new file mode 100644 index 00000000000..dca606556d0 --- /dev/null +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/index.js @@ -0,0 +1,28 @@ +import Vue from 'vue'; +import StatusSelect from './components/status_select.vue'; +import issuableBulkUpdateActions from './issuable_bulk_update_actions'; +import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar'; + +export function initBulkUpdateSidebar(prefixId) { + const el = document.querySelector('.issues-bulk-update'); + + if (!el) { + return; + } + + issuableBulkUpdateActions.init({ prefixId }); + new IssuableBulkUpdateSidebar(); // eslint-disable-line no-new +} + +export function initIssueStatusSelect() { + const el = document.querySelector('.js-issue-status'); + + if (!el) { + return null; + } + + return new Vue({ + el, + render: (createElement) => createElement(StatusSelect), + }); +} diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/init_issue_status_select.js b/app/assets/javascripts/issuable/bulk_update_sidebar/init_issue_status_select.js deleted file mode 100644 index 43179a86d70..00000000000 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/init_issue_status_select.js +++ /dev/null @@ -1,17 +0,0 @@ -import Vue from 'vue'; -import StatusSelect from './components/status_select.vue'; - -export default function initIssueStatusSelect() { - const el = document.querySelector('.js-issue-status'); - - if (!el) { - return null; - } - - return new Vue({ - el, - render(h) { - return h(StatusSelect); - }, - }); -} diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js index 1eb3ffc9808..d46354e240a 100644 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js @@ -1,12 +1,9 @@ /* eslint-disable class-methods-use-this, no-new */ import $ from 'jquery'; -import { property } from 'lodash'; - -import issuableEventHub from '~/issues_list/eventhub'; +import issuableEventHub from '~/issues/list/eventhub'; import LabelsSelect from '~/labels/labels_select'; import MilestoneSelect from '~/milestones/milestone_select'; -import initIssueStatusSelect from './init_issue_status_select'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; import subscriptionSelect from './subscription_select'; @@ -17,8 +14,6 @@ const SIDEBAR_COLLAPSED_CLASS = 'right-sidebar-collapsed issuable-bulk-update-si export default class IssuableBulkUpdateSidebar { constructor() { - this.vueIssuablesListFeature = property(['gon', 'features', 'vueIssuablesList'])(window); - this.initDomElements(); this.bindEvents(); this.initDropdowns(); @@ -57,7 +52,6 @@ export default class IssuableBulkUpdateSidebar { initDropdowns() { new LabelsSelect(); new MilestoneSelect(); - initIssueStatusSelect(); subscriptionSelect(); if (IS_EE) { @@ -145,7 +139,7 @@ export default class IssuableBulkUpdateSidebar { } toggleCheckboxDisplay(show) { - this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show || this.vueIssuablesListFeature); + this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show); this.$issueChecks.toggleClass(HIDDEN_CLASS, !show); } diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar.js b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar.js deleted file mode 100644 index 179c2b83c6c..00000000000 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar.js +++ /dev/null @@ -1,19 +0,0 @@ -import issuableBulkUpdateActions from './issuable_bulk_update_actions'; -import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar'; - -export default { - bulkUpdateSidebar: null, - - init(prefixId) { - const bulkUpdateEl = document.querySelector('.issues-bulk-update'); - const alreadyInitialized = Boolean(this.bulkUpdateSidebar); - - if (bulkUpdateEl && !alreadyInitialized) { - issuableBulkUpdateActions.init({ prefixId }); - - this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar(); - } - - return this.bulkUpdateSidebar; - }, -}; diff --git a/app/assets/javascripts/issuable/components/csv_import_modal.vue b/app/assets/javascripts/issuable/components/csv_import_modal.vue index b72abe14ee1..7e2cbf03801 100644 --- a/app/assets/javascripts/issuable/components/csv_import_modal.vue +++ b/app/assets/javascripts/issuable/components/csv_import_modal.vue @@ -60,7 +60,11 @@ export default { <form ref="form" :action="importCsvIssuesPath" enctype="multipart/form-data" method="post"> <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> <p>{{ $options.i18n.mainText }}</p> - <gl-form-group :label="$options.i18n.uploadCsvFileText" label-for="file"> + <gl-form-group + :label="$options.i18n.uploadCsvFileText" + class="gl-text-truncate" + label-for="file" + > <input id="file" type="file" name="file" accept=".csv,text/csv" /> </gl-form-group> <p class="text-secondary"> diff --git a/app/assets/javascripts/issuable/index.js b/app/assets/javascripts/issuable/index.js index 072422944f5..57bad5182e7 100644 --- a/app/assets/javascripts/issuable/index.js +++ b/app/assets/javascripts/issuable/index.js @@ -11,7 +11,9 @@ import IssuableHeaderWarnings from './components/issuable_header_warnings.vue'; export function initCsvImportExportButtons() { const el = document.querySelector('.js-csv-import-export-buttons'); - if (!el) return null; + if (!el) { + return null; + } const { showExportButton, @@ -42,23 +44,24 @@ export function initCsvImportExportButtons() { maxAttachmentSize, showLabel, }, - render(h) { - return h(CsvImportExportButtons, { + render: (createElement) => + createElement(CsvImportExportButtons, { props: { exportCsvPath, issuableCount: parseInt(issuableCount, 10), }, - }); - }, + }), }); } export function initIssuableByEmail() { - Vue.use(GlToast); - const el = document.querySelector('.js-issuable-by-email'); - if (!el) return null; + if (!el) { + return null; + } + + Vue.use(GlToast); const { initialEmail, @@ -79,9 +82,7 @@ export function initIssuableByEmail() { markdownHelpPath, resetPath, }, - render(h) { - return h(IssuableByEmail); - }, + render: (createElement) => createElement(IssuableByEmail), }); } @@ -89,7 +90,7 @@ export function initIssuableHeaderWarnings(store) { const el = document.getElementById('js-issuable-header-warnings'); if (!el) { - return false; + return null; } const { hidden } = el.dataset; @@ -98,18 +99,18 @@ export function initIssuableHeaderWarnings(store) { el, store, provide: { hidden: parseBoolean(hidden) }, - render(createElement) { - return createElement(IssuableHeaderWarnings); - }, + render: (createElement) => createElement(IssuableHeaderWarnings), }); } export function initIssuableSidebar() { - const sidebarOptEl = document.querySelector('.js-sidebar-options'); + const el = document.querySelector('.js-sidebar-options'); - if (!sidebarOptEl) return; + if (!el) { + return; + } - const sidebarOptions = getSidebarOptions(sidebarOptEl); + const sidebarOptions = getSidebarOptions(el); new IssuableContext(sidebarOptions.currentUser); // eslint-disable-line no-new Sidebar.initialize(); diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js index b7b123dfd5f..4b9a42da178 100644 --- a/app/assets/javascripts/issues/constants.js +++ b/app/assets/javascripts/issues/constants.js @@ -19,6 +19,12 @@ export const IssuableType = { Alert: 'alert', }; +export const IssueType = { + Issue: 'issue', + Incident: 'incident', + TestCase: 'test_case', +}; + export const WorkspaceType = { project: 'project', group: 'group', diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js index ae6e6bf02e4..5d36396bc6e 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js @@ -3,13 +3,13 @@ import { init as initConfidentialMergeRequest, isConfidentialIssue, canCreateConfidentialMergeRequest, -} from './confidential_merge_request'; -import confidentialMergeRequestState from './confidential_merge_request/state'; -import DropLab from './filtered_search/droplab/drop_lab_deprecated'; -import ISetter from './filtered_search/droplab/plugins/input_setter'; -import createFlash from './flash'; -import axios from './lib/utils/axios_utils'; -import { __, sprintf } from './locale'; +} from '~/confidential_merge_request'; +import confidentialMergeRequestState from '~/confidential_merge_request/state'; +import DropLab from '~/filtered_search/droplab/drop_lab_deprecated'; +import ISetter from '~/filtered_search/droplab/plugins/input_setter'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __, sprintf } from '~/locale'; // Todo: Remove this when fixing issue in input_setter plugin const InputSetter = { ...ISetter }; diff --git a/app/assets/javascripts/issues/form.js b/app/assets/javascripts/issues/form.js deleted file mode 100644 index 33371d065f9..00000000000 --- a/app/assets/javascripts/issues/form.js +++ /dev/null @@ -1,24 +0,0 @@ -/* eslint-disable no-new */ - -import $ from 'jquery'; -import IssuableForm from 'ee_else_ce/issuable/issuable_form'; -import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -import GLForm from '~/gl_form'; -import { initTitleSuggestions, initTypePopover } from '~/issues/new'; -import LabelsSelect from '~/labels/labels_select'; -import MilestoneSelect from '~/milestones/milestone_select'; -import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; - -export default () => { - new ShortcutsNavigation(); - new GLForm($('.issue-form')); - new IssuableForm($('.issue-form')); - new LabelsSelect(); - new MilestoneSelect(); - new IssuableTemplateSelectors({ - warnTemplateOverride: true, - }); - - initTitleSuggestions(); - initTypePopover(); -}; diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js new file mode 100644 index 00000000000..2ee9ac2a682 --- /dev/null +++ b/app/assets/javascripts/issues/index.js @@ -0,0 +1,88 @@ +import $ from 'jquery'; +import IssuableForm from 'ee_else_ce/issuable/issuable_form'; +import loadAwardsHandler from '~/awards_handler'; +import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; +import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; +import GLForm from '~/gl_form'; +import { initIssuableHeaderWarnings, initIssuableSidebar } from '~/issuable'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; +import { IssueType } from '~/issues/constants'; +import Issue from '~/issues/issue'; +import { initTitleSuggestions, initTypePopover } from '~/issues/new'; +import { initRelatedMergeRequests } from '~/issues/related_merge_requests'; +import { + initHeaderActions, + initIncidentApp, + initIssueApp, + initSentryErrorStackTrace, +} from '~/issues/show'; +import { parseIssuableData } from '~/issues/show/utils/parse_data'; +import LabelsSelect from '~/labels/labels_select'; +import MilestoneSelect from '~/milestones/milestone_select'; +import initNotesApp from '~/notes'; +import { store } from '~/notes/stores'; +import ZenMode from '~/zen_mode'; +import FilteredSearchServiceDesk from './filtered_search_service_desk'; + +export function initFilteredSearchServiceDesk() { + if (document.querySelector('.filtered-search')) { + const supportBotData = JSON.parse( + document.querySelector('.js-service-desk-issues').dataset.supportBot, + ); + const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData); + filteredSearchManager.setup(); + } +} + +export function initForm() { + new GLForm($('.issue-form')); // eslint-disable-line no-new + new IssuableForm($('.issue-form')); // eslint-disable-line no-new + new IssuableTemplateSelectors({ warnTemplateOverride: true }); // eslint-disable-line no-new + new LabelsSelect(); // eslint-disable-line no-new + new MilestoneSelect(); // eslint-disable-line no-new + new ShortcutsNavigation(); // eslint-disable-line no-new + + initTitleSuggestions(); + initTypePopover(); +} + +export function initShow() { + const el = document.getElementById('js-issuable-app'); + + if (!el) { + return; + } + + const { issueType, ...issuableData } = parseIssuableData(el); + + if (issueType === IssueType.Incident) { + initIncidentApp(issuableData); + initHeaderActions(store, IssueType.Incident); + } else { + initIssueApp(issuableData, store); + initHeaderActions(store); + } + + new Issue(); // eslint-disable-line no-new + new ShortcutsIssuable(); // eslint-disable-line no-new + new ZenMode(); // eslint-disable-line no-new + initIssuableHeaderWarnings(store); + initIssuableSidebar(); + initNotesApp(); + initRelatedMergeRequests(); + initSentryErrorStackTrace(); + + const awardEmojiEl = document.getElementById('js-vue-awards-block'); + + if (awardEmojiEl) { + import('~/emoji/awards_app') + .then((m) => m.default(awardEmojiEl)) + .catch(() => {}); + } else { + loadAwardsHandler(); + } + + import(/* webpackChunkName: 'design_management' */ '~/design_management') + .then((module) => module.default()) + .catch(() => {}); +} diff --git a/app/assets/javascripts/issues/init_filtered_search_service_desk.js b/app/assets/javascripts/issues/init_filtered_search_service_desk.js deleted file mode 100644 index 1901802c11c..00000000000 --- a/app/assets/javascripts/issues/init_filtered_search_service_desk.js +++ /dev/null @@ -1,11 +0,0 @@ -import FilteredSearchServiceDesk from './filtered_search_service_desk'; - -export function initFilteredSearchServiceDesk() { - if (document.querySelector('.filtered-search')) { - const supportBotData = JSON.parse( - document.querySelector('.js-service-desk-issues').dataset.supportBot, - ); - const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData); - filteredSearchManager.setup(); - } -} diff --git a/app/assets/javascripts/issues/issue.js b/app/assets/javascripts/issues/issue.js index c471875654b..8e27f547b5c 100644 --- a/app/assets/javascripts/issues/issue.js +++ b/app/assets/javascripts/issues/issue.js @@ -1,11 +1,11 @@ import $ from 'jquery'; import { joinPaths } from '~/lib/utils/url_utility'; -import CreateMergeRequestDropdown from '~/create_merge_request_dropdown'; import createFlash from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import axios from '~/lib/utils/axios_utils'; import { addDelimiter } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; +import CreateMergeRequestDropdown from './create_merge_request_dropdown'; export default class Issue { constructor() { diff --git a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue index aece7372182..aece7372182 100644 --- a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue +++ b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue 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 6ced1080b71..8b15e801f02 100644 --- a/app/assets/javascripts/issues_list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -11,9 +11,9 @@ import { import * as Sentry from '@sentry/browser'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { orderBy } from 'lodash'; -import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql'; -import getIssuesCountsQuery from 'ee_else_ce/issues_list/queries/get_issues_counts.query.graphql'; -import IssueCardTimeInfo from 'ee_else_ce/issues_list/components/issue_card_time_info.vue'; +import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; +import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql'; +import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue'; import createFlash, { FLASH_TYPES } from '~/flash'; import { TYPE_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -41,7 +41,7 @@ import { TOKEN_TYPE_TYPE, UPDATED_DESC, urlSortParams, -} from '~/issues_list/constants'; +} from '~/issues/list/constants'; import { convertToApiParams, convertToSearchQuery, @@ -51,7 +51,7 @@ import { getInitialPageParams, getSortKey, getSortOptions, -} from '~/issues_list/utils'; +} from '~/issues/list/utils'; import axios from '~/lib/utils/axios_utils'; import { scrollUp } from '~/lib/utils/scroll_utils'; import { getParameterByName, joinPaths } from '~/lib/utils/url_utility'; @@ -517,10 +517,9 @@ export default { }, async handleBulkUpdateClick() { if (!this.hasInitBulkEdit) { - const initBulkUpdateSidebar = await import( - '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar' - ); - initBulkUpdateSidebar.default.init('issuable_'); + const bulkUpdateSidebar = await import('~/issuable/bulk_update_sidebar'); + bulkUpdateSidebar.initBulkUpdateSidebar('issuable_'); + bulkUpdateSidebar.initIssueStatusSelect(); const usersSelect = await import('~/users_select'); const UsersSelect = usersSelect.default; diff --git a/app/assets/javascripts/issues_list/components/jira_issues_import_status_app.vue b/app/assets/javascripts/issues/list/components/jira_issues_import_status_app.vue index fb1dbef666c..fb1dbef666c 100644 --- a/app/assets/javascripts/issues_list/components/jira_issues_import_status_app.vue +++ b/app/assets/javascripts/issues/list/components/jira_issues_import_status_app.vue diff --git a/app/assets/javascripts/issues_list/components/new_issue_dropdown.vue b/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue index e749579af80..71f84050ba8 100644 --- a/app/assets/javascripts/issues_list/components/new_issue_dropdown.vue +++ b/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue @@ -7,7 +7,7 @@ import { GlSearchBoxByType, } from '@gitlab/ui'; import createFlash from '~/flash'; -import searchProjectsQuery from '~/issues_list/queries/search_projects.query.graphql'; +import searchProjectsQuery from '~/issues/list/queries/search_projects.query.graphql'; import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues/list/constants.js index c9eaf0b9908..4a380848b4f 100644 --- a/app/assets/javascripts/issues_list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -9,62 +9,6 @@ import { OPERATOR_IS_NOT, } from '~/vue_shared/components/filtered_search_bar/constants'; -// Maps sort order as it appears in the URL query to API `order_by` and `sort` params. -const PRIORITY = 'priority'; -const ASC = 'asc'; -const DESC = 'desc'; -const CREATED_AT = 'created_at'; -const UPDATED_AT = 'updated_at'; -const DUE_DATE = 'due_date'; -const MILESTONE_DUE = 'milestone_due'; -const POPULARITY = 'popularity'; -const WEIGHT = 'weight'; -const LABEL_PRIORITY = 'label_priority'; -const TITLE = 'title'; -export const RELATIVE_POSITION = 'relative_position'; -export const LOADING_LIST_ITEMS_LENGTH = 8; -export const PAGE_SIZE = 20; -export const PAGE_SIZE_MANUAL = 100; - -export const sortOrderMap = { - priority: { order_by: PRIORITY, sort: ASC }, // asc and desc are flipped for some reason - created_date: { order_by: CREATED_AT, sort: DESC }, - created_asc: { order_by: CREATED_AT, sort: ASC }, - updated_desc: { order_by: UPDATED_AT, sort: DESC }, - updated_asc: { order_by: UPDATED_AT, sort: ASC }, - milestone_due_desc: { order_by: MILESTONE_DUE, sort: DESC }, - milestone: { order_by: MILESTONE_DUE, sort: ASC }, - due_date_desc: { order_by: DUE_DATE, sort: DESC }, - due_date: { order_by: DUE_DATE, sort: ASC }, - popularity: { order_by: POPULARITY, sort: DESC }, - popularity_asc: { order_by: POPULARITY, sort: ASC }, - label_priority: { order_by: LABEL_PRIORITY, sort: ASC }, // asc and desc are flipped - relative_position: { order_by: RELATIVE_POSITION, sort: ASC }, - weight_desc: { order_by: WEIGHT, sort: DESC }, - weight: { order_by: WEIGHT, sort: ASC }, - title: { order_by: TITLE, sort: ASC }, - title_desc: { order_by: TITLE, sort: DESC }, -}; - -export const availableSortOptionsJira = [ - { - id: 1, - title: __('Created date'), - sortDirection: { - descending: 'created_desc', - ascending: 'created_asc', - }, - }, - { - id: 2, - title: __('Last updated'), - sortDirection: { - descending: 'updated_desc', - ascending: 'updated_asc', - }, - }, -]; - export const i18n = { anonymousSearchingMessage: __('You must sign in to search for specific terms.'), calendarLabel: __('Subscribe to calendar'), @@ -108,11 +52,13 @@ export const i18n = { upvotes: __('Upvotes'), }; -export const JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY = 'jira-import-success-alert-hide-map'; - +export const MAX_LIST_SIZE = 10; +export const PAGE_SIZE = 20; +export const PAGE_SIZE_MANUAL = 100; export const PARAM_DUE_DATE = 'due_date'; export const PARAM_SORT = 'sort'; export const PARAM_STATE = 'state'; +export const RELATIVE_POSITION = 'relative_position'; export const defaultPageSizeParams = { firstPageSize: PAGE_SIZE, @@ -183,8 +129,6 @@ export const urlSortParams = { [TITLE_DESC]: 'title_desc', }; -export const MAX_LIST_SIZE = 10; - export const API_PARAM = 'apiParam'; export const URL_PARAM = 'urlParam'; export const NORMAL_FILTER = 'normalFilter'; diff --git a/app/assets/javascripts/issues_list/eventhub.js b/app/assets/javascripts/issues/list/eventhub.js index e31806ad199..e31806ad199 100644 --- a/app/assets/javascripts/issues_list/eventhub.js +++ b/app/assets/javascripts/issues/list/eventhub.js diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues/list/index.js index 9d2ec8b32d2..01cc82ed8fd 100644 --- a/app/assets/javascripts/issues_list/index.js +++ b/app/assets/javascripts/issues/list/index.js @@ -1,11 +1,10 @@ import produce from 'immer'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql'; -import IssuesListApp from 'ee_else_ce/issues_list/components/issues_list_app.vue'; +import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; +import IssuesListApp from 'ee_else_ce/issues/list/components/issues_list_app.vue'; import createDefaultClient from '~/lib/graphql'; -import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; -import IssuablesListApp from './components/issuables_list_app.vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; import JiraIssuesImportStatusRoot from './components/jira_issues_import_status_app.vue'; export function mountJiraIssuesListApp() { @@ -45,35 +44,6 @@ export function mountJiraIssuesListApp() { }); } -export function mountIssuablesListApp() { - if (!gon.features?.vueIssuablesList) { - return; - } - - document.querySelectorAll('.js-issuables-list').forEach((el) => { - const { canBulkEdit, emptyStateMeta = {}, scopedLabelsAvailable, ...data } = el.dataset; - - return new Vue({ - el, - provide: { - scopedLabelsAvailable: parseBoolean(scopedLabelsAvailable), - }, - render(createElement) { - return createElement(IssuablesListApp, { - props: { - ...data, - emptyStateMeta: - Object.keys(emptyStateMeta).length !== 0 - ? convertObjectPropsToCamelCase(JSON.parse(emptyStateMeta)) - : {}, - canBulkEdit: Boolean(canBulkEdit), - }, - }); - }, - }); - }); -} - export function mountIssuesListApp() { const el = document.querySelector('.js-issues-list'); diff --git a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql index be8deb3fe97..be8deb3fe97 100644 --- a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql diff --git a/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql index 1a345fd2877..1a345fd2877 100644 --- a/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql +++ b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql diff --git a/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues_list_details.query.graphql index a53dba8c7c8..a53dba8c7c8 100644 --- a/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql +++ b/app/assets/javascripts/issues/list/queries/get_issues_list_details.query.graphql diff --git a/app/assets/javascripts/issues_list/queries/issue.fragment.graphql b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql index 07dae3fd756..07dae3fd756 100644 --- a/app/assets/javascripts/issues_list/queries/issue.fragment.graphql +++ b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql diff --git a/app/assets/javascripts/issues_list/queries/label.fragment.graphql b/app/assets/javascripts/issues/list/queries/label.fragment.graphql index bb1d8f1ac9b..bb1d8f1ac9b 100644 --- a/app/assets/javascripts/issues_list/queries/label.fragment.graphql +++ b/app/assets/javascripts/issues/list/queries/label.fragment.graphql diff --git a/app/assets/javascripts/issues_list/queries/milestone.fragment.graphql b/app/assets/javascripts/issues/list/queries/milestone.fragment.graphql index 3cdf69bf585..3cdf69bf585 100644 --- a/app/assets/javascripts/issues_list/queries/milestone.fragment.graphql +++ b/app/assets/javascripts/issues/list/queries/milestone.fragment.graphql diff --git a/app/assets/javascripts/issues_list/queries/reorder_issues.mutation.graphql b/app/assets/javascripts/issues/list/queries/reorder_issues.mutation.graphql index 160026a4742..160026a4742 100644 --- a/app/assets/javascripts/issues_list/queries/reorder_issues.mutation.graphql +++ b/app/assets/javascripts/issues/list/queries/reorder_issues.mutation.graphql diff --git a/app/assets/javascripts/issues_list/queries/search_labels.query.graphql b/app/assets/javascripts/issues/list/queries/search_labels.query.graphql index 44b57317161..44b57317161 100644 --- a/app/assets/javascripts/issues_list/queries/search_labels.query.graphql +++ b/app/assets/javascripts/issues/list/queries/search_labels.query.graphql diff --git a/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql b/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql index e7eb08104a6..e7eb08104a6 100644 --- a/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql +++ b/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql diff --git a/app/assets/javascripts/issues_list/queries/search_projects.query.graphql b/app/assets/javascripts/issues/list/queries/search_projects.query.graphql index bd2f9bc2340..bd2f9bc2340 100644 --- a/app/assets/javascripts/issues_list/queries/search_projects.query.graphql +++ b/app/assets/javascripts/issues/list/queries/search_projects.query.graphql diff --git a/app/assets/javascripts/issues_list/queries/search_users.query.graphql b/app/assets/javascripts/issues/list/queries/search_users.query.graphql index 92517ad35d0..92517ad35d0 100644 --- a/app/assets/javascripts/issues_list/queries/search_users.query.graphql +++ b/app/assets/javascripts/issues/list/queries/search_users.query.graphql diff --git a/app/assets/javascripts/issues_list/queries/user.fragment.graphql b/app/assets/javascripts/issues/list/queries/user.fragment.graphql index 3e5bc0f7b93..3e5bc0f7b93 100644 --- a/app/assets/javascripts/issues_list/queries/user.fragment.graphql +++ b/app/assets/javascripts/issues/list/queries/user.fragment.graphql diff --git a/app/assets/javascripts/issues_list/utils.js b/app/assets/javascripts/issues/list/utils.js index 99946e4e851..2919bbbfef8 100644 --- a/app/assets/javascripts/issues_list/utils.js +++ b/app/assets/javascripts/issues/list/utils.js @@ -36,7 +36,7 @@ import { urlSortParams, WEIGHT_ASC, WEIGHT_DESC, -} from '~/issues_list/constants'; +} from '~/issues/list/constants'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import { __ } from '~/locale'; import { @@ -72,7 +72,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) }, { id: 3, - title: __('Last updated'), + title: __('Updated date'), sortDirection: { ascending: UPDATED_ASC, descending: UPDATED_DESC, diff --git a/app/assets/javascripts/issues/manual_ordering.js b/app/assets/javascripts/issues/manual_ordering.js index 9613246d6a6..c78505d0610 100644 --- a/app/assets/javascripts/issues/manual_ordering.js +++ b/app/assets/javascripts/issues/manual_ordering.js @@ -20,7 +20,7 @@ const updateIssue = (url, issueList, { move_before_id, move_after_id }) => }); }); -const initManualOrdering = (draggableSelector = 'li.issue') => { +const initManualOrdering = () => { const issueList = document.querySelector('.manual-ordering'); if (!issueList || !(gon.current_user_id > 0)) { @@ -37,14 +37,14 @@ const initManualOrdering = (draggableSelector = 'li.issue') => { group: { name: 'issues', }, - draggable: draggableSelector, + draggable: 'li.issue', onStart: () => { sortableStart(); }, onUpdate: (event) => { const el = event.item; - const url = el.getAttribute('url') || el.dataset.url; + const url = el.getAttribute('url'); const prev = el.previousElementSibling; const next = el.nextElementSibling; diff --git a/app/assets/javascripts/issues/new/index.js b/app/assets/javascripts/issues/new/index.js index 59a7cbec627..f96cacf2595 100644 --- a/app/assets/javascripts/issues/new/index.js +++ b/app/assets/javascripts/issues/new/index.js @@ -5,8 +5,6 @@ import TitleSuggestions from './components/title_suggestions.vue'; import TypePopover from './components/type_popover.vue'; export function initTitleSuggestions() { - Vue.use(VueApollo); - const el = document.getElementById('js-suggestions'); const issueTitle = document.getElementById('issue_title'); @@ -14,6 +12,8 @@ export function initTitleSuggestions() { return undefined; } + Vue.use(VueApollo); + const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), }); diff --git a/app/assets/javascripts/issues/related_merge_requests/index.js b/app/assets/javascripts/issues/related_merge_requests/index.js index ce33cf7df1d..5045f7e1a2a 100644 --- a/app/assets/javascripts/issues/related_merge_requests/index.js +++ b/app/assets/javascripts/issues/related_merge_requests/index.js @@ -2,23 +2,21 @@ import Vue from 'vue'; import RelatedMergeRequests from './components/related_merge_requests.vue'; import createStore from './store'; -export default function initRelatedMergeRequests() { - const relatedMergeRequestsElement = document.querySelector('#js-related-merge-requests'); +export function initRelatedMergeRequests() { + const el = document.querySelector('#js-related-merge-requests'); - if (relatedMergeRequestsElement) { - const { endpoint, projectPath, projectNamespace } = relatedMergeRequestsElement.dataset; - - // eslint-disable-next-line no-new - new Vue({ - el: relatedMergeRequestsElement, - components: { - RelatedMergeRequests, - }, - store: createStore(), - render: (createElement) => - createElement('related-merge-requests', { - props: { endpoint, projectNamespace, projectPath }, - }), - }); + if (!el) { + return undefined; } + + const { endpoint, projectPath, projectNamespace } = el.dataset; + + return new Vue({ + el, + store: createStore(), + render: (createElement) => + createElement(RelatedMergeRequests, { + props: { endpoint, projectNamespace, projectPath }, + }), + }); } diff --git a/app/assets/javascripts/issues/sentry_error_stack_trace/index.js b/app/assets/javascripts/issues/sentry_error_stack_trace/index.js deleted file mode 100644 index 8e9ee25e7a8..00000000000 --- a/app/assets/javascripts/issues/sentry_error_stack_trace/index.js +++ /dev/null @@ -1,22 +0,0 @@ -import Vue from 'vue'; -import store from '~/error_tracking/store'; -import SentryErrorStackTrace from './components/sentry_error_stack_trace.vue'; - -export default function initSentryErrorStacktrace() { - const sentryErrorStackTraceEl = document.querySelector('#js-sentry-error-stack-trace'); - if (sentryErrorStackTraceEl) { - const { issueStackTracePath } = sentryErrorStackTraceEl.dataset; - // eslint-disable-next-line no-new - new Vue({ - el: sentryErrorStackTraceEl, - components: { - SentryErrorStackTrace, - }, - store, - render: (createElement) => - createElement('sentry-error-stack-trace', { - props: { issueStackTracePath }, - }), - }); - } -} diff --git a/app/assets/javascripts/issues/show.js b/app/assets/javascripts/issues/show.js deleted file mode 100644 index e43e56d7b4e..00000000000 --- a/app/assets/javascripts/issues/show.js +++ /dev/null @@ -1,59 +0,0 @@ -import loadAwardsHandler from '~/awards_handler'; -import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; -import { initIssuableHeaderWarnings, initIssuableSidebar } from '~/issuable'; -import { IssuableType } from '~/vue_shared/issuable/show/constants'; -import Issue from '~/issues/issue'; -import { initIncidentApp, initIncidentHeaderActions } from '~/issues/show/incident'; -import { initIssuableApp, initIssueHeaderActions } from '~/issues/show/issue'; -import { parseIssuableData } from '~/issues/show/utils/parse_data'; -import initNotesApp from '~/notes'; -import { store } from '~/notes/stores'; -import initRelatedMergeRequestsApp from '~/issues/related_merge_requests'; -import initSentryErrorStackTraceApp from '~/issues/sentry_error_stack_trace'; -import ZenMode from '~/zen_mode'; - -export default function initShowIssue() { - initNotesApp(); - - const initialDataEl = document.getElementById('js-issuable-app'); - const { issueType, ...issuableData } = parseIssuableData(initialDataEl); - - switch (issueType) { - case IssuableType.Incident: - initIncidentApp(issuableData); - initIncidentHeaderActions(store); - break; - case IssuableType.Issue: - initIssuableApp(issuableData, store); - initIssueHeaderActions(store); - break; - default: - initIssueHeaderActions(store); - break; - } - - initIssuableHeaderWarnings(store); - initSentryErrorStackTraceApp(); - initRelatedMergeRequestsApp(); - - import(/* webpackChunkName: 'design_management' */ '~/design_management') - .then((module) => module.default()) - .catch(() => {}); - - new ZenMode(); // eslint-disable-line no-new - - if (issueType !== IssuableType.TestCase) { - const awardEmojiEl = document.getElementById('js-vue-awards-block'); - - new Issue(); // eslint-disable-line no-new - new ShortcutsIssuable(); // eslint-disable-line no-new - initIssuableSidebar(); - if (awardEmojiEl) { - import('~/emoji/awards_app') - .then((m) => m.default(awardEmojiEl)) - .catch(() => {}); - } else { - loadAwardsHandler(); - } - } -} diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index eeaf865a35f..0490728c6bc 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -6,7 +6,7 @@ import { IssuableStatus, IssuableStatusText, IssuableType } from '~/issues/const import Poll from '~/lib/utils/poll'; import { visitUrl } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; -import { IssueTypePath, IncidentTypePath, IncidentType, POLLING_DELAY } from '../constants'; +import { ISSUE_TYPE_PATH, INCIDENT_TYPE_PATH, INCIDENT_TYPE, POLLING_DELAY } from '../constants'; import eventHub from '../event_hub'; import getIssueStateQuery from '../queries/get_issue_state.query.graphql'; import Service from '../services/index'; @@ -378,15 +378,15 @@ export default { .then((data) => { if ( !window.location.pathname.includes(data.web_url) && - issueState.issueType !== IncidentType + issueState.issueType !== INCIDENT_TYPE ) { visitUrl(data.web_url); } if (issueState.isDirty) { const URI = - issueState.issueType === IncidentType - ? data.web_url.replace(IssueTypePath, IncidentTypePath) + issueState.issueType === INCIDENT_TYPE + ? data.web_url.replace(ISSUE_TYPE_PATH, INCIDENT_TYPE_PATH) : data.web_url; visitUrl(URI); } diff --git a/app/assets/javascripts/issues/show/components/fields/type.vue b/app/assets/javascripts/issues/show/components/fields/type.vue index 9110a6924b4..75d0b9e5e76 100644 --- a/app/assets/javascripts/issues/show/components/fields/type.vue +++ b/app/assets/javascripts/issues/show/components/fields/type.vue @@ -2,7 +2,7 @@ import { GlFormGroup, GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; import { capitalize } from 'lodash'; import { __ } from '~/locale'; -import { IssuableTypes, IncidentType } from '../../constants'; +import { issuableTypes, INCIDENT_TYPE } from '../../constants'; import getIssueStateQuery from '../../queries/get_issue_state.query.graphql'; import updateIssueStateMutation from '../../queries/update_issue_state.mutation.graphql'; @@ -12,7 +12,7 @@ export const i18n = { export default { i18n, - IssuableTypes, + issuableTypes, components: { GlFormGroup, GlIcon, @@ -45,7 +45,7 @@ export default { return capitalize(issueType); }, shouldShowIncident() { - return this.issueType === IncidentType || this.canCreateIncident; + return this.issueType === INCIDENT_TYPE || this.canCreateIncident; }, }, methods: { @@ -59,7 +59,7 @@ export default { }); }, isShown(type) { - return type.value !== IncidentType || this.shouldShowIncident; + return type.value !== INCIDENT_TYPE || this.shouldShowIncident; }, }, }; @@ -81,7 +81,7 @@ export default { toggle-class="dropdown-menu-toggle" > <gl-dropdown-item - v-for="type in $options.IssuableTypes" + v-for="type in $options.issuableTypes" v-show="isShown(type)" :key="type.value" :is-checked="issueState.issueType === type.value" diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index 700ef92a0f3..8ba08472ea0 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -11,9 +11,8 @@ import { import { mapActions, mapGetters, mapState } from 'vuex'; import createFlash, { FLASH_TYPES } from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; -import { IssuableType } from '~/vue_shared/issuable/show/constants'; -import { IssuableStatus } from '~/issues/constants'; -import { IssueStateEvent } from '~/issues/show/constants'; +import { IssuableStatus, IssueType } from '~/issues/constants'; +import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { visitUrl } from '~/lib/utils/url_utility'; import { s__, __, sprintf } from '~/locale'; @@ -83,7 +82,7 @@ export default { default: '', }, issueType: { - default: IssuableType.Issue, + default: IssueType.Issue, }, newIssuePath: { default: '', @@ -106,8 +105,8 @@ export default { }, issueTypeText() { const issueTypeTexts = { - [IssuableType.Issue]: s__('HeaderAction|issue'), - [IssuableType.Incident]: s__('HeaderAction|incident'), + [IssueType.Issue]: s__('HeaderAction|issue'), + [IssueType.Incident]: s__('HeaderAction|incident'), }; return issueTypeTexts[this.issueType] ?? this.issueType; @@ -163,7 +162,7 @@ export default { input: { iid: this.iid.toString(), projectPath: this.projectPath, - stateEvent: this.isClosed ? IssueStateEvent.Reopen : IssueStateEvent.Close, + stateEvent: this.isClosed ? ISSUE_STATE_EVENT_REOPEN : ISSUE_STATE_EVENT_CLOSE, }, }, }) diff --git a/app/assets/javascripts/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue b/app/assets/javascripts/issues/show/components/sentry_error_stack_trace.vue index 1530e9a15b5..1530e9a15b5 100644 --- a/app/assets/javascripts/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue +++ b/app/assets/javascripts/issues/show/components/sentry_error_stack_trace.vue diff --git a/app/assets/javascripts/issues/show/constants.js b/app/assets/javascripts/issues/show/constants.js index 35f3bcdad70..a100aaf88ad 100644 --- a/app/assets/javascripts/issues/show/constants.js +++ b/app/assets/javascripts/issues/show/constants.js @@ -1,22 +1,20 @@ import { __ } from '~/locale'; -export const IssueStateEvent = { - Close: 'CLOSE', - Reopen: 'REOPEN', -}; - -export const STATUS_PAGE_PUBLISHED = __('Published on status page'); +export const INCIDENT_TYPE = 'incident'; +export const INCIDENT_TYPE_PATH = 'issues/incident'; +export const ISSUE_STATE_EVENT_CLOSE = 'CLOSE'; +export const ISSUE_STATE_EVENT_REOPEN = 'REOPEN'; +export const ISSUE_TYPE_PATH = 'issues'; export const JOIN_ZOOM_MEETING = __('Join Zoom meeting'); +export const POLLING_DELAY = 2000; +export const STATUS_PAGE_PUBLISHED = __('Published on status page'); -export const IssuableTypes = [ +export const issuableTypes = [ { value: 'issue', text: __('Issue'), icon: 'issue-type-issue' }, { value: 'incident', text: __('Incident'), icon: 'issue-type-incident' }, ]; -export const IssueTypePath = 'issues'; -export const IncidentTypePath = 'issues/incident'; -export const IncidentType = 'incident'; - -export const issueState = { issueType: undefined, isDirty: false }; - -export const POLLING_DELAY = 2000; +export const issueState = { + issueType: undefined, + isDirty: false, +}; diff --git a/app/assets/javascripts/issues/show/incident.js b/app/assets/javascripts/issues/show/index.js index a260c31e1da..7f5a0e32f72 100644 --- a/app/assets/javascripts/issues/show/incident.js +++ b/app/assets/javascripts/issues/show/index.js @@ -1,11 +1,15 @@ import Vue from 'vue'; +import { mapGetters } from 'vuex'; +import errorTrackingStore from '~/error_tracking/store'; import { parseBoolean } from '~/lib/utils/common_utils'; -import issuableApp from './components/app.vue'; -import incidentTabs from './components/incidents/incident_tabs.vue'; -import { issueState, IncidentType } from './constants'; +import { scrollToTargetOnResize } from '~/lib/utils/resize_observer'; +import IssueApp from './components/app.vue'; +import HeaderActions from './components/header_actions.vue'; +import IncidentTabs from './components/incidents/incident_tabs.vue'; +import SentryErrorStackTrace from './components/sentry_error_stack_trace.vue'; +import { INCIDENT_TYPE, issueState } from './constants'; import apolloProvider from './graphql'; import getIssueStateQuery from './queries/get_issue_state.query.graphql'; -import HeaderActions from './components/header_actions.vue'; const bootstrapApollo = (state = {}) => { return apolloProvider.clients.defaultClient.cache.writeQuery({ @@ -16,7 +20,7 @@ const bootstrapApollo = (state = {}) => { }); }; -export function initIncidentApp(issuableData = {}) { +export function initIncidentApp(issueData = {}) { const el = document.getElementById('js-issuable-app'); if (!el) { @@ -34,18 +38,15 @@ export function initIncidentApp(issuableData = {}) { projectId, slaFeatureAvailable, uploadMetricsFeatureAvailable, - } = issuableData; + } = issueData; const fullPath = `${projectNamespace}/${projectPath}`; return new Vue({ el, apolloProvider, - components: { - issuableApp, - }, provide: { - issueType: IncidentType, + issueType: INCIDENT_TYPE, canCreateIncident, canUpdate, fullPath, @@ -55,10 +56,10 @@ export function initIncidentApp(issuableData = {}) { uploadMetricsFeatureAvailable: parseBoolean(uploadMetricsFeatureAvailable), }, render(createElement) { - return createElement('issuable-app', { + return createElement(IssueApp, { props: { - ...issuableData, - descriptionComponent: incidentTabs, + ...issueData, + descriptionComponent: IncidentTabs, showTitleBorder: false, }, }); @@ -66,7 +67,46 @@ export function initIncidentApp(issuableData = {}) { }); } -export function initIncidentHeaderActions(store) { +export function initIssueApp(issueData, store) { + const el = document.getElementById('js-issuable-app'); + + if (!el) { + return undefined; + } + + if (gon?.features?.fixCommentScroll) { + scrollToTargetOnResize(); + } + + bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); + + const { canCreateIncident, ...issueProps } = issueData; + + return new Vue({ + el, + apolloProvider, + store, + provide: { + canCreateIncident, + }, + computed: { + ...mapGetters(['getNoteableData']), + }, + render(createElement) { + return createElement(IssueApp, { + props: { + ...issueProps, + isConfidential: this.getNoteableData?.confidential, + isLocked: this.getNoteableData?.discussion_locked, + issuableStatus: this.getNoteableData?.state, + id: this.getNoteableData?.id, + }, + }); + }, + }); +} + +export function initHeaderActions(store, type = '') { const el = document.querySelector('.js-issue-header-actions'); if (!el) { @@ -75,12 +115,15 @@ export function initIncidentHeaderActions(store) { bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); + const canCreate = + type === INCIDENT_TYPE ? el.dataset.canCreateIncident : el.dataset.canCreateIssue; + return new Vue({ el, apolloProvider, store, provide: { - canCreateIssue: parseBoolean(el.dataset.canCreateIncident), + canCreateIssue: parseBoolean(canCreate), canDestroyIssue: parseBoolean(el.dataset.canDestroyIssue), canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic), canReopenIssue: parseBoolean(el.dataset.canReopenIssue), @@ -99,3 +142,20 @@ export function initIncidentHeaderActions(store) { render: (createElement) => createElement(HeaderActions), }); } + +export function initSentryErrorStackTrace() { + const el = document.querySelector('#js-sentry-error-stack-trace'); + + if (!el) { + return undefined; + } + + const { issueStackTracePath } = el.dataset; + + return new Vue({ + el, + store: errorTrackingStore, + render: (createElement) => + createElement(SentryErrorStackTrace, { props: { issueStackTracePath } }), + }); +} diff --git a/app/assets/javascripts/issues/show/issue.js b/app/assets/javascripts/issues/show/issue.js deleted file mode 100644 index 60e90934af8..00000000000 --- a/app/assets/javascripts/issues/show/issue.js +++ /dev/null @@ -1,86 +0,0 @@ -import Vue from 'vue'; -import { mapGetters } from 'vuex'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import IssuableApp from './components/app.vue'; -import HeaderActions from './components/header_actions.vue'; -import { issueState } from './constants'; -import apolloProvider from './graphql'; -import getIssueStateQuery from './queries/get_issue_state.query.graphql'; - -const bootstrapApollo = (state = {}) => { - return apolloProvider.clients.defaultClient.cache.writeQuery({ - query: getIssueStateQuery, - data: { - issueState: state, - }, - }); -}; - -export function initIssuableApp(issuableData, store) { - const el = document.getElementById('js-issuable-app'); - - if (!el) { - return undefined; - } - - bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); - - const { canCreateIncident, ...issuableProps } = issuableData; - - return new Vue({ - el, - apolloProvider, - store, - provide: { - canCreateIncident, - }, - computed: { - ...mapGetters(['getNoteableData']), - }, - render(createElement) { - return createElement(IssuableApp, { - props: { - ...issuableProps, - isConfidential: this.getNoteableData?.confidential, - isLocked: this.getNoteableData?.discussion_locked, - issuableStatus: this.getNoteableData?.state, - id: this.getNoteableData?.id, - }, - }); - }, - }); -} - -export function initIssueHeaderActions(store) { - const el = document.querySelector('.js-issue-header-actions'); - - if (!el) { - return undefined; - } - - bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); - - return new Vue({ - el, - apolloProvider, - store, - provide: { - canCreateIssue: parseBoolean(el.dataset.canCreateIssue), - canDestroyIssue: parseBoolean(el.dataset.canDestroyIssue), - canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic), - canReopenIssue: parseBoolean(el.dataset.canReopenIssue), - canReportSpam: parseBoolean(el.dataset.canReportSpam), - canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue), - iid: el.dataset.iid, - isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor), - issuePath: el.dataset.issuePath, - issueType: el.dataset.issueType, - newIssuePath: el.dataset.newIssuePath, - projectPath: el.dataset.projectPath, - projectId: el.dataset.projectId, - reportAbusePath: el.dataset.reportAbusePath, - submitAsSpamPath: el.dataset.submitAsSpamPath, - }, - render: (createElement) => createElement(HeaderActions), - }); -} diff --git a/app/assets/javascripts/issues_list/components/issuable.vue b/app/assets/javascripts/issues_list/components/issuable.vue deleted file mode 100644 index 6476d5be38c..00000000000 --- a/app/assets/javascripts/issues_list/components/issuable.vue +++ /dev/null @@ -1,441 +0,0 @@ -<script> -/* - * This is tightly coupled to projects/issues/_issue.html.haml, - * any changes done to the haml need to be reflected here. - */ - -// TODO: need to move this component to graphql - https://gitlab.com/gitlab-org/gitlab/-/issues/221246 -import jiraLogo from '@gitlab/svgs/dist/illustrations/logos/jira.svg'; -import { - GlLink, - GlTooltipDirective as GlTooltip, - GlSprintf, - GlLabel, - GlIcon, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; -import { escape, isNumber } from 'lodash'; -import { isScopedLabel } from '~/lib/utils/common_utils'; -import { - dateInWords, - formatDate, - getDayDifference, - getTimeago, - timeFor, - newDateAsLocaleTime, -} from '~/lib/utils/datetime_utility'; -import { convertToCamelCase } from '~/lib/utils/text_utility'; -import { mergeUrlParams, setUrlFragment, isExternal } from '~/lib/utils/url_utility'; -import { sprintf, __ } from '~/locale'; -import initUserPopovers from '~/user_popovers'; -import IssueAssignees from '~/issuable/components/issue_assignees.vue'; - -export default { - i18n: { - openedAgo: __('created %{timeAgoString} by %{user}'), - openedAgoJira: __('created %{timeAgoString} by %{user} in Jira'), - openedAgoServiceDesk: __('created %{timeAgoString} by %{email} via %{user}'), - }, - components: { - IssueAssignees, - GlLink, - GlLabel, - GlIcon, - GlSprintf, - IssueHealthStatus: () => - import('ee_component/related_items_tree/components/issue_health_status.vue'), - }, - directives: { - GlTooltip, - SafeHtml, - }, - inject: ['scopedLabelsAvailable'], - props: { - issuable: { - type: Object, - required: true, - }, - isBulkEditing: { - type: Boolean, - required: false, - default: false, - }, - selected: { - type: Boolean, - required: false, - default: false, - }, - baseUrl: { - type: String, - required: false, - default() { - return window.location.href; - }, - }, - }, - data() { - return { - jiraLogo, - }; - }, - computed: { - milestoneLink() { - const { title } = this.issuable.milestone; - - return this.issuableLink({ milestone_title: title }); - }, - hasWeight() { - return isNumber(this.issuable.weight); - }, - dueDate() { - return this.issuable.due_date ? newDateAsLocaleTime(this.issuable.due_date) : undefined; - }, - dueDateWords() { - return this.dueDate ? dateInWords(this.dueDate, true) : undefined; - }, - isOverdue() { - return this.dueDate ? this.dueDate < new Date() : false; - }, - isClosed() { - return this.issuable.state === 'closed'; - }, - isJiraIssue() { - return this.issuable.external_tracker === 'jira'; - }, - webUrl() { - return this.issuable.gitlab_web_url || this.issuable.web_url; - }, - isIssuableUrlExternal() { - return isExternal(this.webUrl); - }, - linkTarget() { - return this.isIssuableUrlExternal ? '_blank' : null; - }, - issueCreatedToday() { - return getDayDifference(new Date(this.issuable.created_at), new Date()) < 1; - }, - labelIdsString() { - return JSON.stringify(this.issuable.labels.map((l) => l.id)); - }, - milestoneDueDate() { - const { due_date: dueDate } = this.issuable.milestone || {}; - - return dueDate ? newDateAsLocaleTime(dueDate) : undefined; - }, - milestoneTooltipText() { - if (this.milestoneDueDate) { - return sprintf(__('%{primary} (%{secondary})'), { - primary: formatDate(this.milestoneDueDate, 'mmm d, yyyy'), - secondary: timeFor(this.milestoneDueDate), - }); - } - return __('Milestone'); - }, - issuableAuthor() { - return this.issuable.author; - }, - issuableCreatedAt() { - return getTimeago().format(this.issuable.created_at); - }, - popoverDataAttrs() { - const { id, username, name, avatar_url } = this.issuableAuthor; - - return { - 'data-user-id': id, - 'data-username': username, - 'data-name': name, - 'data-avatar-url': avatar_url, - }; - }, - referencePath() { - return this.issuable.references.relative; - }, - updatedDateString() { - return formatDate(new Date(this.issuable.updated_at), 'mmm d, yyyy h:MMtt'); - }, - updatedDateAgo() { - // snake_case because it's the same i18n string as the HAML view - return sprintf(__('updated %{time_ago}'), { - time_ago: escape(getTimeago().format(this.issuable.updated_at)), - }); - }, - issuableMeta() { - return [ - { - key: 'merge-requests', - visible: this.issuable.merge_requests_count > 0, - value: this.issuable.merge_requests_count, - title: __('Related merge requests'), - dataTestId: 'merge-requests', - class: 'js-merge-requests', - icon: 'merge-request', - }, - { - key: 'upvotes', - visible: this.issuable.upvotes > 0, - value: this.issuable.upvotes, - title: __('Upvotes'), - dataTestId: 'upvotes', - class: 'js-upvotes issuable-upvotes', - icon: 'thumb-up', - }, - { - key: 'downvotes', - visible: this.issuable.downvotes > 0, - value: this.issuable.downvotes, - title: __('Downvotes'), - dataTestId: 'downvotes', - class: 'js-downvotes issuable-downvotes', - icon: 'thumb-down', - }, - { - key: 'blocking-issues', - visible: this.issuable.blocking_issues_count > 0, - value: this.issuable.blocking_issues_count, - title: __('Blocking issues'), - dataTestId: 'blocking-issues', - href: setUrlFragment(this.webUrl, 'related-issues'), - icon: 'issue-block', - }, - { - key: 'comments-count', - visible: !this.isJiraIssue, - value: this.issuable.user_notes_count, - title: __('Comments'), - dataTestId: 'notes-count', - href: setUrlFragment(this.webUrl, 'notes'), - class: { 'no-comments': !this.issuable.user_notes_count, 'issuable-comments': true }, - icon: 'comments', - }, - ]; - }, - healthStatus() { - return convertToCamelCase(this.issuable.health_status); - }, - openedMessage() { - if (this.isJiraIssue) return this.$options.i18n.openedAgoJira; - if (this.issuable.service_desk_reply_to) return this.$options.i18n.openedAgoServiceDesk; - return this.$options.i18n.openedAgo; - }, - }, - mounted() { - // TODO: Refactor user popover to use its own component instead of - // spawning event listeners on Vue-rendered elements. - initUserPopovers([this.$refs.openedAgoByContainer.$el]); - }, - methods: { - issuableLink(params) { - return mergeUrlParams(params, this.baseUrl); - }, - isScoped({ name }) { - return isScopedLabel({ title: name }) && this.scopedLabelsAvailable; - }, - labelHref({ name }) { - if (this.isJiraIssue) { - return this.issuableLink({ 'labels[]': name }); - } - - return this.issuableLink({ 'label_name[]': name }); - }, - onSelect(ev) { - this.$emit('select', { - issuable: this.issuable, - selected: ev.target.checked, - }); - }, - issuableMetaComponent(href) { - return href ? 'gl-link' : 'span'; - }, - }, - - confidentialTooltipText: __('Confidential'), -}; -</script> -<template> - <li - :id="`issue_${issuable.id}`" - class="issue" - :class="{ today: issueCreatedToday, closed: isClosed }" - :data-id="issuable.id" - :data-labels="labelIdsString" - :data-url="webUrl" - data-qa-selector="issue_container" - :data-qa-issue-title="issuable.title" - > - <div class="gl-display-flex"> - <!-- Bulk edit checkbox --> - <div v-if="isBulkEditing" class="gl-mr-3"> - <input - :id="`selected_issue_${issuable.id}`" - :checked="selected" - class="selected-issuable" - type="checkbox" - :data-id="issuable.id" - @input="onSelect" - /> - </div> - - <!-- Issuable info container --> - <!-- Issuable main info --> - <div class="gl-flex-grow-1"> - <div class="title"> - <span class="issue-title-text"> - <gl-icon - v-if="issuable.confidential" - v-gl-tooltip - name="eye-slash" - class="gl-vertical-align-text-bottom" - :size="16" - :title="$options.confidentialTooltipText" - :aria-label="$options.confidentialTooltipText" - /> - <gl-link - :href="webUrl" - :target="linkTarget" - data-testid="issuable-title" - data-qa-selector="issue_link" - > - {{ issuable.title }} - <gl-icon - v-if="isIssuableUrlExternal" - name="external-link" - class="gl-vertical-align-text-bottom gl-ml-2" - /> - </gl-link> - </span> - <span - v-if="issuable.has_tasks" - class="gl-ml-2 task-status gl-display-none d-sm-inline-block" - >{{ issuable.task_status }}</span - > - </div> - - <div class="issuable-info"> - <span class="js-ref-path gl-mr-4 mr-sm-0"> - <span - v-if="isJiraIssue" - v-safe-html="jiraLogo" - class="svg-container logo-container" - data-testid="jira-logo" - ></span> - {{ referencePath }} - </span> - - <span data-testid="openedByMessage" class="gl-display-none d-sm-inline-block gl-mr-4"> - · - <gl-sprintf :message="openedMessage"> - <template #timeAgoString> - <span>{{ issuableCreatedAt }}</span> - </template> - <template #user> - <gl-link - ref="openedAgoByContainer" - v-bind="popoverDataAttrs" - :href="issuableAuthor.web_url" - :target="linkTarget" - >{{ issuableAuthor.name }}</gl-link - > - </template> - <template #email> - <span>{{ issuable.service_desk_reply_to }}</span> - </template> - </gl-sprintf> - </span> - - <gl-link - v-if="issuable.milestone" - v-gl-tooltip - class="gl-display-none d-sm-inline-block gl-mr-4 js-milestone milestone" - :href="milestoneLink" - :title="milestoneTooltipText" - > - <gl-icon name="clock" class="s16 gl-vertical-align-text-bottom" /> - {{ issuable.milestone.title }} - </gl-link> - - <span - v-if="dueDate" - v-gl-tooltip - class="gl-display-none d-sm-inline-block gl-mr-4 js-due-date" - :class="{ cred: isOverdue }" - :title="__('Due date')" - > - <gl-icon name="calendar" /> - {{ dueDateWords }} - </span> - - <span - v-if="hasWeight" - v-gl-tooltip - :title="__('Weight')" - class="gl-display-none d-sm-inline-block gl-mr-4" - data-testid="weight" - data-qa-selector="issuable_weight_content" - > - <gl-icon name="weight" class="align-text-bottom" /> - {{ issuable.weight }} - </span> - - <issue-health-status - v-if="issuable.health_status" - :health-status="healthStatus" - class="gl-mr-4 issuable-tag-valign" - /> - - <gl-label - v-for="label in issuable.labels" - :key="label.id" - data-qa-selector="issuable-label" - :target="labelHref(label)" - :background-color="label.color" - :description="label.description" - :color="label.text_color" - :title="label.name" - :scoped="isScoped(label)" - size="sm" - class="gl-mr-2 issuable-tag-valign" - >{{ label.name }}</gl-label - > - </div> - </div> - - <!-- Issuable meta --> - <div - class="gl-flex-shrink-0 gl-display-flex gl-flex-direction-column align-items-end gl-justify-content-center" - > - <div class="controls gl-display-flex"> - <span v-if="isJiraIssue" data-testid="issuable-status">{{ issuable.status }}</span> - <span v-else-if="isClosed" class="issuable-status">{{ __('CLOSED') }}</span> - - <issue-assignees - :assignees="issuable.assignees" - class="gl-align-items-center gl-display-flex gl-ml-3" - :icon-size="16" - img-css-classes="gl-mr-2!" - :max-visible="4" - /> - - <template v-for="meta in issuableMeta"> - <span - v-if="meta.visible" - :key="meta.key" - v-gl-tooltip - class="gl-display-none gl-sm-display-flex gl-align-items-center gl-ml-3" - :class="meta.class" - :data-testid="meta.dataTestId" - :title="meta.title" - > - <component :is="issuableMetaComponent(meta.href)" :href="meta.href"> - <gl-icon v-if="meta.icon" :name="meta.icon" /> - {{ meta.value }} - </component> - </span> - </template> - </div> - <div v-gl-tooltip class="issuable-updated-at" :title="updatedDateString"> - {{ updatedDateAgo }} - </div> - </div> - </div> - </li> -</template> diff --git a/app/assets/javascripts/issues_list/components/issuables_list_app.vue b/app/assets/javascripts/issues_list/components/issuables_list_app.vue deleted file mode 100644 index 71136bf0159..00000000000 --- a/app/assets/javascripts/issues_list/components/issuables_list_app.vue +++ /dev/null @@ -1,426 +0,0 @@ -<script> -import { - GlEmptyState, - GlPagination, - GlDeprecatedSkeletonLoading as GlSkeletonLoading, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; -import { toNumber, omit } from 'lodash'; -import createFlash from '~/flash'; -import axios from '~/lib/utils/axios_utils'; -import { scrollToElement, historyPushState } from '~/lib/utils/common_utils'; -import { setUrlParams, queryToObject, getParameterByName } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; -import initManualOrdering from '~/issues/manual_ordering'; -import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import { - sortOrderMap, - availableSortOptionsJira, - RELATIVE_POSITION, - PAGE_SIZE, - PAGE_SIZE_MANUAL, - LOADING_LIST_ITEMS_LENGTH, -} from '../constants'; -import issuableEventHub from '../eventhub'; -import { emptyStateHelper } from '../service_desk_helper'; -import Issuable from './issuable.vue'; - -/** - * @deprecated Use app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue instead - */ -export default { - LOADING_LIST_ITEMS_LENGTH, - directives: { - SafeHtml, - }, - components: { - GlEmptyState, - GlPagination, - GlSkeletonLoading, - Issuable, - FilteredSearchBar, - }, - props: { - canBulkEdit: { - type: Boolean, - required: false, - default: false, - }, - emptyStateMeta: { - type: Object, - required: true, - }, - endpoint: { - type: String, - required: true, - }, - projectPath: { - type: String, - required: false, - default: '', - }, - sortKey: { - type: String, - required: false, - default: '', - }, - type: { - type: String, - required: false, - default: '', - }, - }, - data() { - return { - availableSortOptionsJira, - filters: {}, - isBulkEditing: false, - issuables: [], - loading: false, - page: getParameterByName('page') !== null ? toNumber(getParameterByName('page')) : 1, - selection: {}, - totalItems: 0, - }; - }, - computed: { - allIssuablesSelected() { - // WARNING: Because we are only keeping track of selected values - // this works, we will need to rethink this if we start tracking - // [id]: false for not selected values. - return this.issuables.length === Object.keys(this.selection).length; - }, - emptyState() { - if (this.issuables.length) { - return {}; // Empty state shouldn't be shown here - } - - if (this.isServiceDesk) { - return emptyStateHelper(this.emptyStateMeta); - } - - if (this.hasFilters) { - return { - title: __('Sorry, your filter produced no results'), - svgPath: this.emptyStateMeta.svgPath, - description: __('To widen your search, change or remove filters above'), - primaryLink: this.emptyStateMeta.createIssuePath, - primaryText: __('New issue'), - }; - } - - if (this.filters.state === 'opened') { - return { - title: __('There are no open issues'), - svgPath: this.emptyStateMeta.svgPath, - description: __('To keep this project going, create a new issue'), - primaryLink: this.emptyStateMeta.createIssuePath, - primaryText: __('New issue'), - }; - } else if (this.filters.state === 'closed') { - return { - title: __('There are no closed issues'), - svgPath: this.emptyStateMeta.svgPath, - }; - } - - return { - title: __('There are no issues to show'), - svgPath: this.emptyStateMeta.svgPath, - description: __( - 'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.', - ), - }; - }, - hasFilters() { - const ignored = ['utf8', 'state', 'scope', 'order_by', 'sort']; - return Object.keys(omit(this.filters, ignored)).length > 0; - }, - isManualOrdering() { - return this.sortKey === RELATIVE_POSITION; - }, - itemsPerPage() { - return this.isManualOrdering ? PAGE_SIZE_MANUAL : PAGE_SIZE; - }, - baseUrl() { - return window.location.href.replace(/(\?.*)?(#.*)?$/, ''); - }, - paginationNext() { - return this.page + 1; - }, - paginationPrev() { - return this.page - 1; - }, - paginationProps() { - const paginationProps = { value: this.page }; - - if (this.totalItems) { - return { - ...paginationProps, - perPage: this.itemsPerPage, - totalItems: this.totalItems, - }; - } - - return { - ...paginationProps, - prevPage: this.paginationPrev, - nextPage: this.paginationNext, - }; - }, - isServiceDesk() { - return this.type === 'service_desk'; - }, - isJira() { - return this.type === 'jira'; - }, - initialFilterValue() { - const value = []; - const { search } = this.getQueryObject(); - - if (search) { - value.push(search); - } - return value; - }, - initialSortBy() { - const { sort } = this.getQueryObject(); - return sort || 'created_desc'; - }, - }, - watch: { - selection() { - // We need to call nextTick here to wait for all of the boxes to be checked and rendered - // before we query the dom in issuable_bulk_update_actions.js. - this.$nextTick(() => { - issuableEventHub.$emit('issuables:updateBulkEdit'); - }); - }, - issuables() { - this.$nextTick(() => { - initManualOrdering(); - }); - }, - }, - mounted() { - if (this.canBulkEdit) { - this.unsubscribeToggleBulkEdit = issuableEventHub.$on('issuables:toggleBulkEdit', (val) => { - this.isBulkEditing = val; - }); - } - this.fetchIssuables(); - }, - beforeDestroy() { - // eslint-disable-next-line @gitlab/no-global-event-off - issuableEventHub.$off('issuables:toggleBulkEdit'); - }, - methods: { - isSelected(issuableId) { - return Boolean(this.selection[issuableId]); - }, - setSelection(ids) { - ids.forEach((id) => { - this.select(id, true); - }); - }, - clearSelection() { - this.selection = {}; - }, - select(id, isSelect = true) { - if (isSelect) { - this.$set(this.selection, id, true); - } else { - this.$delete(this.selection, id); - } - }, - fetchIssuables(pageToFetch) { - this.loading = true; - - this.clearSelection(); - - this.setFilters(); - - return axios - .get(this.endpoint, { - params: { - ...this.filters, - - with_labels_details: true, - page: pageToFetch || this.page, - per_page: this.itemsPerPage, - }, - }) - .then((response) => { - this.loading = false; - this.issuables = response.data; - this.totalItems = Number(response.headers['x-total']); - this.page = Number(response.headers['x-page']); - }) - .catch(() => { - this.loading = false; - return createFlash({ - message: __('An error occurred while loading issues'), - }); - }); - }, - getQueryObject() { - return queryToObject(window.location.search, { gatherArrays: true }); - }, - onPaginate(newPage) { - if (newPage === this.page) return; - - scrollToElement('#content-body'); - - // NOTE: This allows for the params to be updated on pagination - historyPushState( - setUrlParams({ ...this.filters, page: newPage }, window.location.href, true), - ); - - this.fetchIssuables(newPage); - }, - onSelectAll() { - if (this.allIssuablesSelected) { - this.selection = {}; - } else { - this.setSelection(this.issuables.map(({ id }) => id)); - } - }, - onSelectIssuable({ issuable, selected }) { - if (!this.canBulkEdit) return; - - this.select(issuable.id, selected); - }, - setFilters() { - const { - label_name: labels, - milestone_title: milestoneTitle, - 'not[label_name]': excludedLabels, - 'not[milestone_title]': excludedMilestone, - ...filters - } = this.getQueryObject(); - - // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/227880 - - if (milestoneTitle) { - filters.milestone = milestoneTitle; - } - if (Array.isArray(labels)) { - filters.labels = labels.join(','); - } - if (!filters.state) { - filters.state = 'opened'; - } - - if (excludedLabels) { - filters['not[labels]'] = excludedLabels; - } - - if (excludedMilestone) { - filters['not[milestone]'] = excludedMilestone; - } - - Object.assign(filters, sortOrderMap[this.sortKey]); - - this.filters = filters; - }, - refetchIssuables() { - const ignored = ['utf8']; - const params = omit(this.filters, ignored); - - historyPushState(setUrlParams(params, window.location.href, true, true)); - this.fetchIssuables(); - }, - handleFilter(filters) { - const searchTokens = []; - - filters.forEach((filter) => { - if (filter.type === 'filtered-search-term') { - if (filter.value.data) { - searchTokens.push(filter.value.data); - } - } - }); - - if (searchTokens.length) { - this.filters.search = searchTokens.join(' '); - } - this.page = 1; - - this.refetchIssuables(); - }, - handleSort(sort) { - this.filters.sort = sort; - this.page = 1; - - this.refetchIssuables(); - }, - }, -}; -</script> - -<template> - <div> - <filtered-search-bar - v-if="isJira" - :namespace="projectPath" - :search-input-placeholder="__('Search Jira issues')" - :tokens="[]" - :sort-options="availableSortOptionsJira" - :initial-filter-value="initialFilterValue" - :initial-sort-by="initialSortBy" - class="row-content-block" - @onFilter="handleFilter" - @onSort="handleSort" - /> - <ul v-if="loading" class="content-list"> - <li v-for="n in $options.LOADING_LIST_ITEMS_LENGTH" :key="n" class="issue gl-px-5! gl-py-5!"> - <gl-skeleton-loading /> - </li> - </ul> - <div v-else-if="issuables.length"> - <div v-if="isBulkEditing" class="issue px-3 py-3 border-bottom border-light"> - <input - id="check-all-issues" - type="checkbox" - :checked="allIssuablesSelected" - class="mr-2" - @click="onSelectAll" - /> - <strong>{{ __('Select all') }}</strong> - </div> - <ul - class="content-list issuable-list issues-list" - :class="{ 'manual-ordering': isManualOrdering }" - > - <issuable - v-for="issuable in issuables" - :key="issuable.id" - class="pr-3" - :class="{ 'user-can-drag': isManualOrdering }" - :issuable="issuable" - :is-bulk-editing="isBulkEditing" - :selected="isSelected(issuable.id)" - :base-url="baseUrl" - @select="onSelectIssuable" - /> - </ul> - <div class="mt-3"> - <gl-pagination - v-bind="paginationProps" - class="gl-justify-content-center" - @input="onPaginate" - /> - </div> - </div> - <gl-empty-state - v-else - :title="emptyState.title" - :svg-path="emptyState.svgPath" - :primary-button-link="emptyState.primaryLink" - :primary-button-text="emptyState.primaryText" - > - <template #description> - <div v-safe-html="emptyState.description"></div> - </template> - </gl-empty-state> - </div> -</template> diff --git a/app/assets/javascripts/issues_list/service_desk_helper.js b/app/assets/javascripts/issues_list/service_desk_helper.js deleted file mode 100644 index 815f338f1a0..00000000000 --- a/app/assets/javascripts/issues_list/service_desk_helper.js +++ /dev/null @@ -1,111 +0,0 @@ -import { __, s__ } from '~/locale'; - -/** - * Generates empty state messages for Service Desk issues list. - * - * @param {emptyStateMeta} emptyStateMeta - Meta data used to generate empty state messages - * @returns {Object} Object containing empty state messages generated using the meta data. - */ -export function generateMessages(emptyStateMeta) { - const { - svgPath, - serviceDeskHelpPage, - serviceDeskAddress, - editProjectPage, - incomingEmailHelpPage, - } = emptyStateMeta; - - const serviceDeskSupportedTitle = s__( - 'ServiceDesk|Use Service Desk to connect with your users and offer customer support through email right inside GitLab', - ); - - const serviceDeskSupportedMessage = s__( - 'ServiceDesk|Issues created from Service Desk emails will appear here. Each comment becomes part of the email conversation.', - ); - - const commonDescription = ` - <span>${serviceDeskSupportedMessage}</span> - <a href="${serviceDeskHelpPage}">${__('Learn more.')}</a>`; - - return { - serviceDeskEnabledAndCanEditProjectSettings: { - title: serviceDeskSupportedTitle, - svgPath, - description: `<p>${s__('ServiceDesk|Your users can send emails to this address:')} - <code>${serviceDeskAddress}</code> - </p> - ${commonDescription}`, - }, - serviceDeskEnabledAndCannotEditProjectSettings: { - title: serviceDeskSupportedTitle, - svgPath, - description: commonDescription, - }, - serviceDeskDisabledAndCanEditProjectSettings: { - title: serviceDeskSupportedTitle, - svgPath, - description: commonDescription, - primaryLink: editProjectPage, - primaryText: s__('ServiceDesk|Enable Service Desk'), - }, - serviceDeskDisabledAndCannotEditProjectSettings: { - title: serviceDeskSupportedTitle, - svgPath, - description: commonDescription, - }, - serviceDeskIsNotSupported: { - title: s__('ServiceDesk|Service Desk is not supported'), - svgPath, - description: s__( - 'ServiceDesk|To enable Service Desk on this instance, an instance administrator must first set up incoming email.', - ), - primaryLink: incomingEmailHelpPage, - primaryText: __('Learn more.'), - }, - serviceDeskIsNotEnabled: { - title: s__('ServiceDesk|Service Desk is not enabled'), - svgPath, - description: s__( - 'ServiceDesk|For help setting up the Service Desk for your instance, please contact an administrator.', - ), - }, - }; -} - -/** - * Returns the attributes used for gl-empty-state in the Service Desk issues list. - * - * @param {Object} emptyStateMeta - Meta data used to generate empty state messages - * @returns {Object} - */ -export function emptyStateHelper(emptyStateMeta) { - const messages = generateMessages(emptyStateMeta); - - const { isServiceDeskSupported, canEditProjectSettings, isServiceDeskEnabled } = emptyStateMeta; - - if (isServiceDeskSupported) { - if (isServiceDeskEnabled && canEditProjectSettings) { - return messages.serviceDeskEnabledAndCanEditProjectSettings; - } - - if (isServiceDeskEnabled && !canEditProjectSettings) { - return messages.serviceDeskEnabledAndCannotEditProjectSettings; - } - - // !isServiceDeskEnabled && canEditProjectSettings - if (canEditProjectSettings) { - return messages.serviceDeskDisabledAndCanEditProjectSettings; - } - - // !isServiceDeskEnabled && !canEditProjectSettings - return messages.serviceDeskDisabledAndCannotEditProjectSettings; - } - - // !serviceDeskSupported && canEditProjectSettings - if (canEditProjectSettings) { - return messages.serviceDeskIsNotSupported; - } - - // !serviceDeskSupported && !canEditProjectSettings - return messages.serviceDeskIsNotEnabled; -} diff --git a/app/assets/javascripts/jira_import/utils/constants.js b/app/assets/javascripts/jira_import/utils/constants.js index 178159be009..4230f85e443 100644 --- a/app/assets/javascripts/jira_import/utils/constants.js +++ b/app/assets/javascripts/jira_import/utils/constants.js @@ -1,5 +1,7 @@ import { __ } from '~/locale'; +export const JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY = 'jira-import-success-alert-hide-map'; + export const debounceWait = 500; export const dropdownLabel = __( diff --git a/app/assets/javascripts/jira_import/utils/jira_import_utils.js b/app/assets/javascripts/jira_import/utils/jira_import_utils.js index 4e3b5b2fbde..bd83dd4d219 100644 --- a/app/assets/javascripts/jira_import/utils/jira_import_utils.js +++ b/app/assets/javascripts/jira_import/utils/jira_import_utils.js @@ -1,5 +1,5 @@ import { last } from 'lodash'; -import { JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY } from '~/issues_list/constants'; +import { JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY } from './constants'; export const IMPORT_STATE = { FAILED: 'failed', diff --git a/app/assets/javascripts/jobs/bridge/app.vue b/app/assets/javascripts/jobs/bridge/app.vue index 67c22712776..c639e49083b 100644 --- a/app/assets/javascripts/jobs/bridge/app.vue +++ b/app/assets/javascripts/jobs/bridge/app.vue @@ -1,20 +1,118 @@ <script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { __, sprintf } from '~/locale'; +import CiHeader from '~/vue_shared/components/header_ci_component.vue'; +import getPipelineQuery from './graphql/queries/pipeline.query.graphql'; import BridgeEmptyState from './components/empty_state.vue'; import BridgeSidebar from './components/sidebar.vue'; +import { SIDEBAR_COLLAPSE_BREAKPOINTS } from './components/constants'; export default { name: 'BridgePageApp', components: { BridgeEmptyState, BridgeSidebar, + CiHeader, + GlLoadingIcon, + }, + inject: ['buildId', 'projectFullPath', 'pipelineIid'], + apollo: { + pipeline: { + query: getPipelineQuery, + variables() { + return { + fullPath: this.projectFullPath, + iid: this.pipelineIid, + }; + }, + update(data) { + if (!data?.project?.pipeline) { + return null; + } + + const { pipeline } = data.project; + const stages = pipeline?.stages.edges.map((edge) => edge.node) || []; + const jobs = stages.map((stage) => stage.jobs.nodes).flat(); + + return { + ...pipeline, + commit: { + ...pipeline.commit, + commit_path: pipeline.commit.webPath, + short_id: pipeline.commit.shortId, + }, + id: getIdFromGraphQLId(pipeline.id), + jobs, + stages, + }; + }, + }, + }, + data() { + return { + isSidebarExpanded: true, + pipeline: {}, + }; + }, + computed: { + bridgeJob() { + return ( + this.pipeline.jobs?.filter( + (job) => getIdFromGraphQLId(job.id) === Number(this.buildId), + )[0] || {} + ); + }, + bridgeName() { + return sprintf(__('Job %{jobName}'), { jobName: this.bridgeJob.name }); + }, + isPipelineLoading() { + return this.$apollo.queries.pipeline.loading; + }, + }, + created() { + window.addEventListener('resize', this.onResize); + }, + mounted() { + this.onResize(); + }, + methods: { + toggleSidebar() { + this.isSidebarExpanded = !this.isSidebarExpanded; + }, + onResize() { + const breakpoint = bp.getBreakpointSize(); + if (SIDEBAR_COLLAPSE_BREAKPOINTS.includes(breakpoint)) { + this.isSidebarExpanded = false; + } else if (!this.isSidebarExpanded) { + this.isSidebarExpanded = true; + } + }, }, }; </script> <template> <div> - <!-- TODO: get job details and show CI header --> - <!-- TODO: add downstream pipeline path --> - <bridge-empty-state downstream-pipeline-path="#" /> - <bridge-sidebar /> + <gl-loading-icon v-if="isPipelineLoading" size="lg" class="gl-mt-4" /> + <div v-else> + <ci-header + class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" + :status="bridgeJob.detailedStatus" + :time="bridgeJob.createdAt" + :user="pipeline.user" + :has-sidebar-button="true" + :item-name="bridgeName" + @clickedSidebarButton="toggleSidebar" + /> + <bridge-empty-state :downstream-pipeline-path="bridgeJob.downstreamPipeline.path" /> + <bridge-sidebar + v-if="isSidebarExpanded" + :bridge-job="bridgeJob" + :commit="pipeline.commit" + :is-sidebar-expanded="isSidebarExpanded" + @toggleSidebar="toggleSidebar" + /> + </div> </div> </template> diff --git a/app/assets/javascripts/jobs/bridge/components/sidebar.vue b/app/assets/javascripts/jobs/bridge/components/sidebar.vue index 68b767408f0..3ba07cf55d1 100644 --- a/app/assets/javascripts/jobs/bridge/components/sidebar.vue +++ b/app/assets/javascripts/jobs/bridge/components/sidebar.vue @@ -1,14 +1,13 @@ <script> import { GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { __ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { JOB_SIDEBAR } from '../../constants'; -import { SIDEBAR_COLLAPSE_BREAKPOINTS } from './constants'; +import CommitBlock from '../../components/commit_block.vue'; export default { styles: { - top: '75px', width: '290px', }, name: 'BridgeSidebar', @@ -18,40 +17,47 @@ export default { retryTriggerJob: __('Retry the trigger job'), retryDownstreamPipeline: __('Retry the downstream pipeline'), }, - borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'], + sectionClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100', 'gl-py-5'], components: { + CommitBlock, GlButton, GlDropdown, GlDropdownItem, TooltipOnTruncate, }, - inject: { - buildName: { - type: String, - default: '', + mixins: [glFeatureFlagsMixin()], + props: { + bridgeJob: { + type: Object, + required: true, + }, + commit: { + type: Object, + required: true, }, }, data() { return { - isSidebarExpanded: true, + topPosition: 0, }; }, - created() { - window.addEventListener('resize', this.onResize); + computed: { + rootStyle() { + return { ...this.$options.styles, top: `${this.topPosition}px` }; + }, }, mounted() { - this.onResize(); + this.setTopPosition(); }, methods: { - toggleSidebar() { - this.isSidebarExpanded = !this.isSidebarExpanded; + onSidebarButtonClick() { + this.$emit('toggleSidebar'); }, - onResize() { - const breakpoint = bp.getBreakpointSize(); - if (SIDEBAR_COLLAPSE_BREAKPOINTS.includes(breakpoint)) { - this.isSidebarExpanded = false; - } else if (!this.isSidebarExpanded) { - this.isSidebarExpanded = true; + setTopPosition() { + const navbarEl = document.querySelector('.js-navbar'); + + if (navbarEl) { + this.topPosition = navbarEl.getBoundingClientRect().bottom; } }, }, @@ -60,19 +66,19 @@ export default { <template> <aside class="gl-fixed gl-right-0 gl-px-5 gl-bg-gray-10 gl-h-full gl-border-l-solid gl-border-1 gl-border-gray-100 gl-z-index-200 gl-overflow-hidden" - :style="this.$options.styles" - :class="{ - 'gl-display-none': !isSidebarExpanded, - }" + :style="rootStyle" > <div class="gl-py-5 gl-display-flex gl-align-items-center"> - <tooltip-on-truncate :title="buildName" truncate-target="child" + <tooltip-on-truncate :title="bridgeJob.name" truncate-target="child" ><h4 class="gl-mb-0 gl-mr-2 gl-text-truncate"> - {{ buildName }} + {{ bridgeJob.name }} </h4> </tooltip-on-truncate> <!-- TODO: implement retry actions --> - <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right"> + <div + v-if="glFeatures.triggerJobRetryAction" + class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right" + > <gl-dropdown :text="$options.i18n.retryButton" category="primary" @@ -90,9 +96,10 @@ export default { category="tertiary" class="gl-md-display-none gl-ml-2" icon="chevron-double-lg-right" - @click="toggleSidebar" + @click="onSidebarButtonClick" /> </div> - <!-- TODO: get job details and show commit block, stage dropdown, jobs list --> + <commit-block :commit="commit" :class="$options.sectionClass" /> + <!-- TODO: show stage dropdown, jobs list --> </aside> </template> diff --git a/app/assets/javascripts/jobs/bridge/graphql/queries/pipeline.query.graphql b/app/assets/javascripts/jobs/bridge/graphql/queries/pipeline.query.graphql new file mode 100644 index 00000000000..338ca9f16c7 --- /dev/null +++ b/app/assets/javascripts/jobs/bridge/graphql/queries/pipeline.query.graphql @@ -0,0 +1,70 @@ +query getPipelineData($fullPath: ID!, $iid: ID!) { + project(fullPath: $fullPath) { + id + pipeline(iid: $iid) { + id + iid + path + sha + ref + refPath + commit { + id + shortId + title + webPath + } + detailedStatus { + id + icon + group + } + stages { + edges { + node { + id + name + jobs { + nodes { + id + createdAt + name + scheduledAt + startedAt + status + triggered + detailedStatus { + id + detailsPath + icon + group + text + tooltip + } + downstreamPipeline { + id + path + } + stage { + id + name + } + } + } + } + } + } + user { + id + avatarUrl + name + username + webPath + webUrl + status { + message + } + } + } + } +} diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue index 97141a27a5e..8e35fd91481 100644 --- a/app/assets/javascripts/jobs/components/job_log_controllers.vue +++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue @@ -107,6 +107,7 @@ export default { :data-confirm="__('Are you sure you want to erase this build?')" class="gl-ml-3" data-testid="job-log-erase-link" + data-confirm-btn-variant="danger" data-method="post" icon="remove" /> diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue index 5451cd21c14..5428f657252 100644 --- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue +++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue @@ -1,5 +1,6 @@ <script> import { mapState } from 'vuex'; +import { GlBadge } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; import { __, sprintf } from '~/locale'; @@ -10,6 +11,7 @@ export default { name: 'JobSidebarDetailsContainer', components: { DetailRow, + GlBadge, }, mixins: [timeagoMixin], computed: { @@ -100,12 +102,7 @@ export default { <p v-if="hasTags" class="build-detail-row" data-testid="job-tags"> <span class="font-weight-bold">{{ __('Tags:') }}</span> - <span - v-for="(tag, i) in job.tags" - :key="i" - class="badge badge-pill badge-primary gl-badge sm" - >{{ tag }}</span - > + <gl-badge v-for="(tag, i) in job.tags" :key="i" variant="info">{{ tag }}</gl-badge> </p> </div> </template> diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js index e078a6c2319..6e958ea1842 100644 --- a/app/assets/javascripts/jobs/index.js +++ b/app/assets/javascripts/jobs/index.js @@ -54,7 +54,13 @@ const initializeJobPage = (element) => { }; const initializeBridgePage = (el) => { - const { buildName, emptyStateIllustrationPath } = el.dataset; + const { + buildId, + downstreamPipelinePath, + emptyStateIllustrationPath, + pipelineIid, + projectFullPath, + } = el.dataset; Vue.use(VueApollo); const apolloProvider = new VueApollo({ @@ -65,8 +71,11 @@ const initializeBridgePage = (el) => { el, apolloProvider, provide: { - buildName, + buildId, + downstreamPipelinePath, emptyStateIllustrationPath, + pipelineIid, + projectFullPath, }, render(h) { return h(BridgeApp); diff --git a/app/assets/javascripts/labels/components/delete_label_modal.vue b/app/assets/javascripts/labels/components/delete_label_modal.vue index 1ff0938d086..2be404de1e1 100644 --- a/app/assets/javascripts/labels/components/delete_label_modal.vue +++ b/app/assets/javascripts/labels/components/delete_label_modal.vue @@ -56,6 +56,7 @@ export default { </gl-sprintf> </template> <gl-sprintf + v-if="subjectName" :message=" __( `%{strongStart}${labelName}%{strongEnd} will be permanently deleted from ${subjectName}. This cannot be undone.`, @@ -66,6 +67,18 @@ export default { <strong>{{ content }}</strong> </template> </gl-sprintf> + <gl-sprintf + v-else + :message=" + __( + `%{strongStart}${labelName}%{strongEnd} will be permanently deleted. This cannot be undone.`, + ) + " + > + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> <template #modal-footer> <gl-button category="secondary" @click="closeModal">{{ __('Cancel') }}</gl-button> <gl-button diff --git a/app/assets/javascripts/labels/create_label_dropdown.js b/app/assets/javascripts/labels/create_label_dropdown.js index 8c166158a44..033ca9dd3ea 100644 --- a/app/assets/javascripts/labels/create_label_dropdown.js +++ b/app/assets/javascripts/labels/create_label_dropdown.js @@ -16,6 +16,7 @@ export default class CreateLabelDropdown { this.$colorPreview = $('.js-dropdown-label-color-preview', this.$el); this.$addList = $('.js-add-list', this.$el); this.$newLabelError = $('.js-label-error', this.$el); + this.$newLabelErrorContent = $('.gl-alert-content', this.$newLabelError); this.$newLabelCreateButton = $('.js-new-label-btn', this.$el); this.$colorSuggestions = $('.suggest-colors-dropdown a', this.$el); @@ -119,7 +120,8 @@ export default class CreateLabelDropdown { .join('<br/>'); } - this.$newLabelError.html(errors).show(); + this.$newLabelErrorContent.html(errors); + this.$newLabelError.show(); } else { const addNewList = this.$addList.is(':checked'); this.$dropdownBack.trigger('click'); diff --git a/app/assets/javascripts/labels/index.js b/app/assets/javascripts/labels/index.js index 22a9c0a89c0..e87ad8d9a06 100644 --- a/app/assets/javascripts/labels/index.js +++ b/app/assets/javascripts/labels/index.js @@ -26,7 +26,7 @@ export function initLabels() { if ($('.prioritized-labels').length) { new LabelManager(); // eslint-disable-line no-new } - $('.label-subscription').each((i, el) => { + $('.js-label-subscription').each((i, el) => { const $el = $(el); if ($el.find('.dropdown-group-label').length) { diff --git a/app/assets/javascripts/lib/mermaid.js b/app/assets/javascripts/lib/mermaid.js new file mode 100644 index 00000000000..d621c9ddf9e --- /dev/null +++ b/app/assets/javascripts/lib/mermaid.js @@ -0,0 +1,61 @@ +import mermaid from 'mermaid'; +import { getParameterByName } from '~/lib/utils/url_utility'; + +const setIframeRenderedSize = (h, w) => { + const { origin } = window.location; + window.parent.postMessage({ h, w }, origin); +}; + +const drawDiagram = (source) => { + const element = document.getElementById('app'); + const insertSvg = (svgCode) => { + element.innerHTML = svgCode; + + const height = parseInt(element.firstElementChild.getAttribute('height'), 10); + const width = parseInt(element.firstElementChild.style.maxWidth, 10); + setIframeRenderedSize(height, width); + }; + mermaid.mermaidAPI.render('mermaid', source, insertSvg); +}; + +const darkModeEnabled = () => getParameterByName('darkMode') === 'true'; + +const initMermaid = () => { + let theme = 'neutral'; + + if (darkModeEnabled()) { + theme = 'dark'; + } + + mermaid.initialize({ + // mermaid core options + mermaid: { + startOnLoad: false, + }, + // mermaidAPI options + theme, + flowchart: { + useMaxWidth: true, + htmlLabels: true, + }, + secure: ['secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'htmlLabels'], + securityLevel: 'strict', + }); +}; + +const addListener = () => { + window.addEventListener( + 'message', + (event) => { + if (event.origin !== window.location.origin) { + return; + } + drawDiagram(event.data); + }, + false, + ); +}; + +addListener(); +initMermaid(); +export default {}; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 7235b38848c..eff00dff7a7 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -181,6 +181,7 @@ export const contentTop = () => { }, () => getOuterHeight('.merge-request-tabs'), () => getOuterHeight('.js-diff-files-changed'), + () => getOuterHeight('.issue-sticky-header.gl-fixed'), ({ desktop }) => { const diffsTabIsActive = window.mrTabs?.currentAction === 'diffs'; let size; @@ -746,3 +747,12 @@ export const isLoggedIn = () => Boolean(window.gon?.current_user_id); */ export const convertArrayOfObjectsToCamelCase = (array) => array.map((o) => convertObjectPropsToCamelCase(o)); + +export const getFirstPropertyValue = (data) => { + if (!data) return null; + + const [key] = Object.keys(data); + if (!key) return null; + + return data[key]; +}; diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index a108b02bcbf..36c6545164e 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -17,7 +17,6 @@ export const BV_HIDE_MODAL = 'bv::hide::modal'; export const BV_HIDE_TOOLTIP = 'bv::hide::tooltip'; export const BV_DROPDOWN_SHOW = 'bv::dropdown::show'; export const BV_DROPDOWN_HIDE = 'bv::dropdown::hide'; -export const BV_COLLAPSE_STATE = 'bv::collapse::state'; export const DEFAULT_TH_CLASSES = 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!'; diff --git a/app/assets/javascripts/lib/utils/resize_observer.js b/app/assets/javascripts/lib/utils/resize_observer.js new file mode 100644 index 00000000000..e72c6fe1679 --- /dev/null +++ b/app/assets/javascripts/lib/utils/resize_observer.js @@ -0,0 +1,58 @@ +import { contentTop } from './common_utils'; + +const interactionEvents = ['mousedown', 'touchstart', 'keydown', 'wheel']; + +export function createResizeObserver() { + return new ResizeObserver((entries) => { + entries.forEach((entry) => { + entry.target.dispatchEvent(new CustomEvent(`ResizeUpdate`, { detail: { entry } })); + }); + }); +} + +// watches for change in size of a container element (e.g. for lazy-loaded images) +// and scroll the target element to the top of the content area +// stop watching after any user input. So if user opens sidebar or manually +// scrolls the page we don't hijack their scroll position +export function scrollToTargetOnResize({ + target = window.location.hash, + container = '#content-body', +} = {}) { + if (!target) return null; + + const ro = createResizeObserver(); + const containerEl = document.querySelector(container); + let interactionListenersAdded = false; + + function keepTargetAtTop() { + const anchorEl = document.querySelector(target); + + if (!anchorEl) return; + + const anchorTop = anchorEl.getBoundingClientRect().top + window.scrollY; + const top = anchorTop - contentTop(); + document.documentElement.scrollTo({ + top, + }); + + if (!interactionListenersAdded) { + interactionEvents.forEach((event) => + // eslint-disable-next-line no-use-before-define + document.addEventListener(event, removeListeners), + ); + interactionListenersAdded = true; + } + } + + function removeListeners() { + interactionEvents.forEach((event) => document.removeEventListener(event, removeListeners)); + + ro.unobserve(containerEl); + containerEl.removeEventListener('ResizeUpdate', keepTargetAtTop); + } + + containerEl.addEventListener('ResizeUpdate', keepTargetAtTop); + + ro.observe(containerEl); + return ro; +} diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index e221a54d9c6..376134afef0 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -101,6 +101,21 @@ function deferredInitialisation() { initFeatureHighlight(); initCopyCodeButton(); + const helpToggle = document.querySelector('.header-help-dropdown-toggle'); + if (helpToggle) { + helpToggle.addEventListener( + 'click', + () => { + import(/* webpackChunkName: 'versionCheck' */ './gitlab_version_check') + .then(({ default: initGitlabVersionCheck }) => { + initGitlabVersionCheck(); + }) + .catch(() => {}); + }, + { once: true }, + ); + } + const search = document.querySelector('#search'); if (search) { search.addEventListener( diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index de733ae75df..e09d16cf680 100644 --- a/app/assets/javascripts/members/components/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -6,6 +6,7 @@ import { canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members import { mergeUrlParams } from '~/lib/utils/url_utility'; import initUserPopovers from '~/user_popovers'; import { + FIELD_KEY_ACTIONS, FIELDS, ACTIVE_TAB_QUERY_PARAM_NAME, TAB_QUERY_PARAM_VALUES, @@ -63,17 +64,10 @@ export default { return state[this.namespace].pagination; }, }), - filteredFields() { + filteredAndModifiedFields() { return FIELDS.filter( (field) => this.tableFields.includes(field.key) && this.showField(field), - ).map((field) => { - const tdClassFunction = this[field.tdClassFunction]; - - return { - ...field, - ...(tdClassFunction && { tdClass: tdClassFunction }), - }; - }); + ).map(this.modifyFieldDefinition); }, userIsLoggedIn() { return this.currentUserId !== null; @@ -100,20 +94,29 @@ export default { ); }, showField(field) { - if (!Object.prototype.hasOwnProperty.call(field, 'showFunction')) { - return true; - } + switch (field.key) { + case FIELD_KEY_ACTIONS: + if (!this.userIsLoggedIn) { + return false; + } - return this[field.showFunction](); + return this.members.some((member) => this.hasActionButtons(member)); + default: + return true; + } }, - showActionsField() { - if (!this.userIsLoggedIn) { - return false; + modifyFieldDefinition(field) { + switch (field.key) { + case FIELD_KEY_ACTIONS: + return { + ...field, + tdClass: this.actionsFieldTdClass, + }; + default: + return field; } - - return this.members.some((member) => this.hasActionButtons(member)); }, - tdClassActions(value, key, member) { + actionsFieldTdClass(value, key, member) { if (this.hasActionButtons(member)) { return 'col-actions'; } @@ -219,7 +222,7 @@ export default { data-testid="members-table" head-variant="white" stacked="lg" - :fields="filteredFields" + :fields="filteredAndModifiedFields" :items="members" primary-key="id" thead-class="border-bottom" diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js index f5ca881ab0d..62241eaed04 100644 --- a/app/assets/javascripts/members/constants.js +++ b/app/assets/javascripts/members/constants.js @@ -1,8 +1,18 @@ import { __ } from '~/locale'; +export const FIELD_KEY_ACCOUNT = 'account'; +export const FIELD_KEY_SOURCE = 'source'; +export const FIELD_KEY_GRANTED = 'granted'; +export const FIELD_KEY_INVITED = 'invited'; +export const FIELD_KEY_REQUESTED = 'requested'; +export const FIELD_KEY_MAX_ROLE = 'maxRole'; +export const FIELD_KEY_EXPIRATION = 'expiration'; +export const FIELD_KEY_LAST_SIGN_IN = 'lastSignIn'; +export const FIELD_KEY_ACTIONS = 'actions'; + export const FIELDS = [ { - key: 'account', + key: FIELD_KEY_ACCOUNT, label: __('Account'), sort: { asc: 'name_asc', @@ -10,13 +20,13 @@ export const FIELDS = [ }, }, { - key: 'source', + key: FIELD_KEY_SOURCE, label: __('Source'), thClass: 'col-meta', tdClass: 'col-meta', }, { - key: 'granted', + key: FIELD_KEY_GRANTED, label: __('Access granted'), thClass: 'col-meta', tdClass: 'col-meta', @@ -26,19 +36,19 @@ export const FIELDS = [ }, }, { - key: 'invited', + key: FIELD_KEY_INVITED, label: __('Invited'), thClass: 'col-meta', tdClass: 'col-meta', }, { - key: 'requested', + key: FIELD_KEY_REQUESTED, label: __('Requested'), thClass: 'col-meta', tdClass: 'col-meta', }, { - key: 'maxRole', + key: FIELD_KEY_MAX_ROLE, label: __('Max role'), thClass: 'col-max-role', tdClass: 'col-max-role', @@ -48,13 +58,13 @@ export const FIELDS = [ }, }, { - key: 'expiration', + key: FIELD_KEY_EXPIRATION, label: __('Expiration'), thClass: 'col-expiration', tdClass: 'col-expiration', }, { - key: 'lastSignIn', + key: FIELD_KEY_LAST_SIGN_IN, label: __('Last sign-in'), sort: { asc: 'recent_sign_in', @@ -62,10 +72,8 @@ export const FIELDS = [ }, }, { - key: 'actions', + key: FIELD_KEY_ACTIONS, thClass: 'col-actions', - showFunction: 'showActionsField', - tdClassFunction: 'tdClassActions', }, ]; diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue index d988ad8d8ca..29c181f04fb 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue @@ -143,6 +143,7 @@ export default { </template> <template #right-actions> <gl-dropdown + v-if="!deleteButtonDisabled" icon="ellipsis_v" text="More actions" :text-sr-only="true" @@ -150,11 +151,7 @@ export default { no-caret right > - <gl-dropdown-item - variant="danger" - :disabled="deleteButtonDisabled" - @click="$emit('delete')" - > + <gl-dropdown-item variant="danger" @click="$emit('delete')"> {{ __('Delete image repository') }} </gl-dropdown-item> </gl-dropdown> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue deleted file mode 100644 index a16d95a6b30..00000000000 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue +++ /dev/null @@ -1,44 +0,0 @@ -<script> -import { GlEmptyState } from '@gitlab/ui'; -import { - NO_TAGS_TITLE, - NO_TAGS_MESSAGE, - MISSING_OR_DELETED_IMAGE_TITLE, - MISSING_OR_DELETED_IMAGE_MESSAGE, -} from '../../constants/index'; - -export default { - components: { - GlEmptyState, - }, - props: { - noContainersImage: { - type: String, - required: false, - default: '', - }, - isEmptyImage: { - type: Boolean, - default: false, - required: false, - }, - }, - computed: { - title() { - return this.isEmptyImage ? MISSING_OR_DELETED_IMAGE_TITLE : NO_TAGS_TITLE; - }, - description() { - return this.isEmptyImage ? MISSING_OR_DELETED_IMAGE_MESSAGE : NO_TAGS_MESSAGE; - }, - }, -}; -</script> - -<template> - <gl-empty-state - :title="title" - :svg-path="noContainersImage" - :description="description" - class="gl-mx-auto gl-my-0" - /> -</template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue index 2d32295b537..4fda4058711 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue @@ -1,28 +1,38 @@ <script> +import { GlEmptyState } from '@gitlab/ui'; import createFlash from '~/flash'; import { n__ } from '~/locale'; import { joinPaths } from '~/lib/utils/url_utility'; import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; + +import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; +import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import { REMOVE_TAGS_BUTTON_TITLE, TAGS_LIST_TITLE, GRAPHQL_PAGE_SIZE, FETCH_IMAGES_LIST_ERROR_MESSAGE, + NAME_SORT_FIELD, + NO_TAGS_TITLE, + NO_TAGS_MESSAGE, + NO_TAGS_MATCHING_FILTERS_TITLE, + NO_TAGS_MATCHING_FILTERS_DESCRIPTION, } from '../../constants/index'; import getContainerRepositoryTagsQuery from '../../graphql/queries/get_container_repository_tags.query.graphql'; -import EmptyState from './empty_state.vue'; import TagsListRow from './tags_list_row.vue'; import TagsLoader from './tags_loader.vue'; export default { name: 'TagsList', components: { + GlEmptyState, TagsListRow, - EmptyState, TagsLoader, RegistryList, + PersistedSearch, }, inject: ['config'], + props: { id: { type: [Number, String], @@ -44,6 +54,7 @@ export default { required: false, }, }, + searchConfig: { NAME_SORT_FIELD }, i18n: { REMOVE_TAGS_BUTTON_TITLE, TAGS_LIST_TITLE, @@ -51,6 +62,9 @@ export default { apollo: { containerRepository: { query: getContainerRepositoryTagsQuery, + skip() { + return !this.sort; + }, variables() { return this.queryVariables; }, @@ -62,6 +76,8 @@ export default { data() { return { containerRepository: {}, + filters: {}, + sort: null, }; }, computed: { @@ -78,6 +94,8 @@ export default { return { id: joinPaths(this.config.gidPrefix, `${this.id}`), first: GRAPHQL_PAGE_SIZE, + name: this.filters?.name, + sort: this.sort, }; }, showMultiDeleteButton() { @@ -87,7 +105,16 @@ export default { return this.tags.length === 0; }, isLoading() { - return this.isImageLoading || this.$apollo.queries.containerRepository.loading; + return this.isImageLoading || this.$apollo.queries.containerRepository.loading || !this.sort; + }, + hasFilters() { + return this.filters?.name; + }, + emptyStateTitle() { + return this.hasFilters ? NO_TAGS_MATCHING_FILTERS_TITLE : NO_TAGS_TITLE; + }, + emptyStateDescription() { + return this.hasFilters ? NO_TAGS_MATCHING_FILTERS_DESCRIPTION : NO_TAGS_MESSAGE; }, }, methods: { @@ -114,15 +141,47 @@ export default { }, }); }, + handleSearchUpdate({ sort, filters }) { + this.sort = sort; + + const parsed = { + name: '', + }; + + // This takes in account the fact that we will be adding more filters types + // this is why is an object and not an array or a simple string + this.filters = filters.reduce((acc, filter) => { + if (filter.type === FILTERED_SEARCH_TERM) { + return { + ...acc, + name: `${acc.name} ${filter.value.data}`.trim(), + }; + } + return acc; + }, parsed); + }, }, }; </script> <template> <div> + <persisted-search + class="gl-mb-5" + :sortable-fields="[$options.searchConfig.NAME_SORT_FIELD]" + :default-order="$options.searchConfig.NAME_SORT_FIELD.orderBy" + default-sort="asc" + @update="handleSearchUpdate" + /> <tags-loader v-if="isLoading" /> <template v-else> - <empty-state v-if="hasNoTags" :no-containers-image="config.noContainersImage" /> + <gl-empty-state + v-if="hasNoTags" + :title="emptyStateTitle" + :svg-path="config.noContainersImage" + :description="emptyStateDescription" + class="gl-mx-auto gl-my-0" + /> <template v-else> <registry-list :title="listTitle" 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 0556fd298aa..15d92ab0ef7 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 @@ -107,11 +107,8 @@ export default { isInvalidTag() { return !this.tag.digest; }, - isCheckboxDisabled() { - return this.isInvalidTag || this.disabled; - }, isDeleteDisabled() { - return this.isInvalidTag || this.disabled || !this.tag.canDelete; + return this.disabled || !this.tag.canDelete; }, }, }; @@ -122,7 +119,7 @@ export default { <template #left-action> <gl-form-checkbox v-if="tag.canDelete" - :disabled="isCheckboxDisabled" + :disabled="disabled" class="gl-m-0" :checked="selected" @change="$emit('select')" diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js index f7beec2c935..17adaec7a7d 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js @@ -2,3 +2,5 @@ import { s__, __ } from '~/locale'; export const ROOT_IMAGE_TEXT = s__('ContainerRegistry|Root image'); export const MORE_ACTIONS_TEXT = __('More actions'); + +export const NAME_SORT_FIELD = { orderBy: 'NAME', label: __('Name') }; diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js index 19e1a75fb2f..8b8769a884d 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js @@ -116,6 +116,13 @@ export const ROOT_IMAGE_TOOLTIP = s__( 'ContainerRegistry|Image repository with no name located at the project URL.', ); +export const NO_TAGS_MATCHING_FILTERS_TITLE = s__( + 'ContainerRegistry|The filter returned no results', +); +export const NO_TAGS_MATCHING_FILTERS_DESCRIPTION = s__( + 'ContainerRegistry|Please try different search criteria', +); + // Parameters export const DEFAULT_PAGE = 1; 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 d21a154d1b8..7fa950ccfd0 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 @@ -1,4 +1,5 @@ import { s__, __ } from '~/locale'; +import { NAME_SORT_FIELD } from './common'; // Translations strings @@ -49,5 +50,5 @@ export const GRAPHQL_PAGE_SIZE = 10; export const SORT_FIELDS = [ { orderBy: 'UPDATED', label: __('Updated') }, { orderBy: 'CREATED', label: __('Created') }, - { orderBy: 'NAME', label: __('Name') }, + NAME_SORT_FIELD, ]; diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql index 502382010f9..d753d33a02c 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql @@ -6,11 +6,13 @@ query getContainerRepositoryTags( $last: Int $after: String $before: String + $name: String + $sort: ContainerRepositoryTagSort ) { containerRepository(id: $id) { id tagsCount - tags(after: $after, before: $before, first: $first, last: $last) { + tags(after: $after, before: $before, first: $first, last: $last, name: $name, sort: $sort) { nodes { digest location diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js index 246a6768593..ca5bd8d6964 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js @@ -3,7 +3,8 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; import PerformancePlugin from '~/performance/vue_performance_plugin'; import Translate from '~/vue_shared/translate'; -import RegistryBreadcrumb from './components/registry_breadcrumb.vue'; +import RegistryBreadcrumb from '~/packages_and_registries/shared/components/registry_breadcrumb.vue'; +import { renderBreadcrumb } from '~/packages_and_registries/shared/utils'; import { apolloProvider } from './graphql/index'; import RegistryExplorer from './pages/index.vue'; import createRouter from './router'; @@ -84,38 +85,8 @@ export default () => { }, }); - const attachBreadcrumb = () => { - const breadCrumbEls = document.querySelectorAll('nav .js-breadcrumbs-list li'); - const breadCrumbEl = breadCrumbEls[breadCrumbEls.length - 1]; - const crumbs = [breadCrumbEl.querySelector('h2')]; - const nestedBreadcrumbEl = document.createElement('div'); - breadCrumbEl.replaceChild(nestedBreadcrumbEl, breadCrumbEl.querySelector('h2')); - return new Vue({ - el: nestedBreadcrumbEl, - router, - apolloProvider, - components: { - RegistryBreadcrumb, - }, - render(createElement) { - // FIXME(@tnir): this is a workaround until the MR gets merged: - // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48115 - const parentEl = breadCrumbEl.parentElement.parentElement; - if (parentEl) { - parentEl.classList.remove('breadcrumbs-container'); - parentEl.classList.add('gl-display-flex'); - parentEl.classList.add('w-100'); - } - // End of FIXME(@tnir) - return createElement('registry-breadcrumb', { - class: breadCrumbEl.className, - props: { - crumbs, - }, - }); - }, - }); + return { + attachBreadcrumb: renderBreadcrumb(router, apolloProvider, RegistryBreadcrumb), + attachMainComponent, }; - - return { attachBreadcrumb, attachMainComponent }; }; diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue index bc6e3091f0e..bb687ffdb89 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue @@ -1,5 +1,5 @@ <script> -import { GlResizeObserverDirective } from '@gitlab/ui'; +import { GlResizeObserverDirective, GlEmptyState } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; @@ -9,7 +9,6 @@ import DeleteImage from '../components/delete_image.vue'; import DeleteAlert from '../components/details_page/delete_alert.vue'; import DeleteModal from '../components/details_page/delete_modal.vue'; import DetailsHeader from '../components/details_page/details_header.vue'; -import EmptyState from '../components/details_page/empty_state.vue'; import PartialCleanupAlert from '../components/details_page/partial_cleanup_alert.vue'; import StatusAlert from '../components/details_page/status_alert.vue'; import TagsList from '../components/details_page/tags_list.vue'; @@ -26,6 +25,8 @@ import { MISSING_OR_DELETED_IMAGE_BREADCRUMB, ROOT_IMAGE_TEXT, GRAPHQL_PAGE_SIZE, + MISSING_OR_DELETED_IMAGE_TITLE, + MISSING_OR_DELETED_IMAGE_MESSAGE, } from '../constants/index'; import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql'; import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql'; @@ -34,13 +35,13 @@ import getContainerRepositoryTagsQuery from '../graphql/queries/get_container_re export default { name: 'RegistryDetailsPage', components: { + GlEmptyState, DeleteAlert, PartialCleanupAlert, DetailsHeader, DeleteModal, TagsList, TagsLoader, - EmptyState, StatusAlert, DeleteImage, }, @@ -49,6 +50,10 @@ export default { }, mixins: [Tracking.mixin()], inject: ['breadCrumbState', 'config'], + i18n: { + MISSING_OR_DELETED_IMAGE_TITLE, + MISSING_OR_DELETED_IMAGE_MESSAGE, + }, apollo: { containerRepository: { query: getContainerRepositoryDetailsQuery, @@ -230,6 +235,12 @@ export default { @cancel="track('cancel_delete')" /> </template> - <empty-state v-else is-empty-image :no-containers-image="config.noContainersImage" /> + <gl-empty-state + v-else + :title="$options.i18n.MISSING_OR_DELETED_IMAGE_TITLE" + :description="$options.i18n.MISSING_OR_DELETED_IMAGE_MESSAGE" + :svg-path="config.noContainersImage" + class="gl-mx-auto gl-my-0" + /> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/composer_installation.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/composer_installation.vue index cc629ae394c..a482c29bf50 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/composer_installation.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/composer_installation.vue @@ -6,6 +6,7 @@ import { TRACKING_ACTION_COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND, TRACKING_ACTION_COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND, TRACKING_LABEL_CODE_INSTRUCTION, + COMPOSER_HELP_PATH, } from '~/packages_and_registries/package_registry/constants'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; @@ -17,7 +18,7 @@ export default { GlLink, GlSprintf, }, - inject: ['composerHelpPath', 'composerConfigRepositoryName', 'composerPath', 'groupListUrl'], + inject: ['groupListUrl'], props: { packageEntity: { type: Object, @@ -27,7 +28,7 @@ export default { computed: { composerRegistryInclude() { // eslint-disable-next-line @gitlab/require-i18n-strings - return `composer config repositories.${this.composerConfigRepositoryName} '{"type": "composer", "url": "${this.composerPath}"}'`; + return `composer config repositories.${this.packageEntity.composerConfigRepositoryUrl} '{"type": "composer", "url": "${this.packageEntity.composerUrl}"}'`; }, composerPackageInclude() { // eslint-disable-next-line @gitlab/require-i18n-strings @@ -51,6 +52,9 @@ export default { TRACKING_ACTION_COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND, TRACKING_LABEL_CODE_INSTRUCTION, }, + links: { + COMPOSER_HELP_PATH, + }, installOptions: [{ value: 'composer', label: s__('PackageRegistry|Show Composer commands') }], }; </script> @@ -79,7 +83,7 @@ export default { <span data-testid="help-text"> <gl-sprintf :message="$options.i18n.infoLine"> <template #link="{ content }"> - <gl-link :href="composerHelpPath" target="_blank">{{ content }}</gl-link> + <gl-link :href="$options.links.COMPOSER_HELP_PATH" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> </span> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/conan_installation.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/conan_installation.vue index 99e27c9d44a..ba0a3fcf5a1 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/conan_installation.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/conan_installation.vue @@ -6,6 +6,7 @@ import { TRACKING_ACTION_COPY_CONAN_COMMAND, TRACKING_ACTION_COPY_CONAN_SETUP_COMMAND, TRACKING_LABEL_CODE_INSTRUCTION, + CONAN_HELP_PATH, } from '~/packages_and_registries/package_registry/constants'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; @@ -17,7 +18,6 @@ export default { GlLink, GlSprintf, }, - inject: ['conanHelpPath', 'conanPath'], props: { packageEntity: { type: Object, @@ -31,7 +31,7 @@ export default { }, conanSetupCommand() { // eslint-disable-next-line @gitlab/require-i18n-strings - return `conan remote add gitlab ${this.conanPath}`; + return `conan remote add gitlab ${this.packageEntity.conanUrl}`; }, }, i18n: { @@ -44,7 +44,7 @@ export default { TRACKING_ACTION_COPY_CONAN_SETUP_COMMAND, TRACKING_LABEL_CODE_INSTRUCTION, }, - + links: { CONAN_HELP_PATH }, installOptions: [{ value: 'conan', label: s__('PackageRegistry|Show Conan commands') }], }; </script> @@ -72,7 +72,7 @@ export default { /> <gl-sprintf :message="$options.i18n.helpText"> <template #link="{ content }"> - <gl-link :href="conanHelpPath" target="_blank">{{ content }}</gl-link> + <gl-link :href="$options.links.CONAN_HELP_PATH" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> </div> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/maven_installation.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/maven_installation.vue index 2070f0bbca0..4510c7a7322 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/maven_installation.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/maven_installation.vue @@ -12,6 +12,7 @@ import { TRACKING_ACTION_COPY_KOTLIN_ADD_TO_SOURCE_COMMAND, TRACKING_LABEL_CODE_INSTRUCTION, TRACKING_LABEL_MAVEN_INSTALLATION, + MAVEN_HELP_PATH, } from '~/packages_and_registries/package_registry/constants'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; @@ -23,7 +24,6 @@ export default { GlLink, GlSprintf, }, - inject: ['mavenHelpPath', 'mavenPath'], props: { packageEntity: { type: Object, @@ -36,6 +36,9 @@ export default { }; }, computed: { + mavenUrl() { + return this.packageEntity.mavenUrl; + }, appGroup() { return this.packageEntity.metadata.appGroup; }, @@ -61,19 +64,19 @@ export default { return `<repositories> <repository> <id>gitlab-maven</id> - <url>${this.mavenPath}</url> + <url>${this.mavenUrl}</url> </repository> </repositories> <distributionManagement> <repository> <id>gitlab-maven</id> - <url>${this.mavenPath}</url> + <url>${this.mavenUrl}</url> </repository> <snapshotRepository> <id>gitlab-maven</id> - <url>${this.mavenPath}</url> + <url>${this.mavenUrl}</url> </snapshotRepository> </distributionManagement>`; }, @@ -86,7 +89,7 @@ export default { gradleGroovyAddSourceCommand() { // eslint-disable-next-line @gitlab/require-i18n-strings return `maven { - url '${this.mavenPath}' + url '${this.mavenUrl}' }`; }, @@ -95,7 +98,7 @@ export default { }, gradleKotlinAddSourceCommand() { - return `maven("${this.mavenPath}")`; + return `maven("${this.mavenUrl}")`; }, showMaven() { return this.instructionType === 'maven'; @@ -126,7 +129,7 @@ export default { TRACKING_LABEL_CODE_INSTRUCTION, TRACKING_LABEL_MAVEN_INSTALLATION, }, - + links: { MAVEN_HELP_PATH }, installOptions: [ { value: 'maven', label: s__('PackageRegistry|Maven XML') }, { value: 'groovy', label: s__('PackageRegistry|Gradle Groovy DSL') }, @@ -185,7 +188,7 @@ export default { /> <gl-sprintf :message="$options.i18n.helpText"> <template #link="{ content }"> - <gl-link :href="mavenHelpPath" target="_blank">{{ content }}</gl-link> + <gl-link :href="$options.links.MAVEN_HELP_PATH" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/npm_installation.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/npm_installation.vue index 2448324549e..7479f748a56 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/npm_installation.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/npm_installation.vue @@ -13,6 +13,7 @@ import { YARN_PACKAGE_MANAGER, PROJECT_PACKAGE_ENDPOINT_TYPE, INSTANCE_PACKAGE_ENDPOINT_TYPE, + NPM_HELP_PATH, } from '~/packages_and_registries/package_registry/constants'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; @@ -25,7 +26,7 @@ export default { GlSprintf, GlFormRadioGroup, }, - inject: ['npmHelpPath', 'npmPath', 'npmProjectPath'], + inject: ['npmInstanceUrl'], props: { packageEntity: { type: Object, @@ -65,7 +66,9 @@ export default { npmSetupCommand(type, endpointType) { const scope = this.packageEntity.name.substring(0, this.packageEntity.name.indexOf('/')); const npmPathForEndpoint = - endpointType === INSTANCE_PACKAGE_ENDPOINT_TYPE ? this.npmPath : this.npmProjectPath; + endpointType === INSTANCE_PACKAGE_ENDPOINT_TYPE + ? this.npmInstanceUrl + : this.packageEntity.npmUrl; if (type === NPM_PACKAGE_MANAGER) { return `echo ${scope}:registry=${npmPathForEndpoint}/ >> .npmrc`; @@ -89,6 +92,7 @@ export default { 'PackageRegistry|You may also need to setup authentication using an auth token. %{linkStart}See the documentation%{linkEnd} to find out more.', ), }, + links: { NPM_HELP_PATH }, installOptions: [ { value: NPM_PACKAGE_MANAGER, label: s__('PackageRegistry|Show NPM commands') }, { value: YARN_PACKAGE_MANAGER, label: s__('PackageRegistry|Show Yarn commands') }, @@ -150,7 +154,7 @@ export default { <gl-sprintf :message="$options.i18n.helpText"> <template #link="{ content }"> - <gl-link :href="npmHelpPath" target="_blank">{{ content }}</gl-link> + <gl-link :href="$options.links.NPM_HELP_PATH" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> </div> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/nuget_installation.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/nuget_installation.vue index 2e9991b7be5..b2007df142c 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/nuget_installation.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/nuget_installation.vue @@ -6,6 +6,7 @@ import { TRACKING_ACTION_COPY_NUGET_INSTALL_COMMAND, TRACKING_ACTION_COPY_NUGET_SETUP_COMMAND, TRACKING_LABEL_CODE_INSTRUCTION, + NUGET_HELP_PATH, } from '~/packages_and_registries/package_registry/constants'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; @@ -17,7 +18,6 @@ export default { GlLink, GlSprintf, }, - inject: ['nugetHelpPath', 'nugetPath'], props: { packageEntity: { type: Object, @@ -29,7 +29,7 @@ export default { return `nuget install ${this.packageEntity.name} -Source "GitLab"`; }, nugetSetupCommand() { - return `nuget source Add -Name "GitLab" -Source "${this.nugetPath}" -UserName <your_username> -Password <your_token>`; + return `nuget source Add -Name "GitLab" -Source "${this.packageEntity.nugetUrl}" -UserName <your_username> -Password <your_token>`; }, }, tracking: { @@ -42,6 +42,7 @@ export default { 'PackageRegistry|For more information on the NuGet registry, %{linkStart}see the documentation%{linkEnd}.', ), }, + links: { NUGET_HELP_PATH }, installOptions: [{ value: 'nuget', label: s__('PackageRegistry|Show Nuget commands') }], }; </script> @@ -68,7 +69,7 @@ export default { /> <gl-sprintf :message="$options.i18n.helpText"> <template #link="{ content }"> - <gl-link :href="nugetHelpPath" target="_blank">{{ content }}</gl-link> + <gl-link :href="$options.links.NUGET_HELP_PATH" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> </div> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue index bf7fe6fb91b..3724e371e01 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue @@ -22,8 +22,12 @@ export default { FileSha, }, mixins: [Tracking.mixin()], - inject: ['canDelete'], props: { + canDelete: { + type: Boolean, + required: false, + default: false, + }, packageFiles: { type: Array, required: false, 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 669adab9df6..a126d30f1ec 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 @@ -7,6 +7,7 @@ import { TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND, TRACKING_ACTION_COPY_PYPI_SETUP_COMMAND, TRACKING_LABEL_CODE_INSTRUCTION, + PYPI_HELP_PATH, } from '~/packages_and_registries/package_registry/constants'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; @@ -18,7 +19,6 @@ export default { GlLink, GlSprintf, }, - inject: ['pypiHelpPath', 'pypiPath', 'pypiSetupPath'], props: { packageEntity: { type: Object, @@ -28,11 +28,11 @@ export default { computed: { pypiPipCommand() { // eslint-disable-next-line @gitlab/require-i18n-strings - return `pip install ${this.packageEntity.name} --extra-index-url ${this.pypiPath}`; + return `pip install ${this.packageEntity.name} --extra-index-url ${this.packageEntity.pypiUrl}`; }, pypiSetupCommand() { return `[gitlab] -repository = ${this.pypiSetupPath} +repository = ${this.packageEntity.pypiSetupUrl} username = __token__ password = <your personal access token>`; }, @@ -50,6 +50,7 @@ password = <your personal access token>`; 'PackageRegistry|For more information on the PyPi registry, %{linkStart}see the documentation%{linkEnd}.', ), }, + links: { PYPI_HELP_PATH }, installOptions: [{ value: 'pypi', label: s__('PackageRegistry|Show PyPi commands') }], }; </script> @@ -86,7 +87,7 @@ password = <your personal access token>`; /> <gl-sprintf :message="$options.i18n.helpText"> <template #link="{ content }"> - <gl-link :href="pypiHelpPath" target="_blank">{{ content }}</gl-link> + <gl-link :href="$options.links.PYPI_HELP_PATH" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> </div> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue index 6fd96c0654f..6222c2e73d7 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui'; +import { GlButton, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui'; import { s__, __ } from '~/locale'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import { @@ -18,7 +18,6 @@ export default { name: 'PackageListRow', components: { GlButton, - GlLink, GlSprintf, GlTruncate, PackageTags, @@ -42,9 +41,8 @@ export default { packageType() { return getPackageTypeLabel(this.packageEntity.packageType); }, - packageLink() { - const { project, id } = this.packageEntity; - return `${project?.webUrl}/-/packages/${getIdFromGraphQLId(id)}`; + packageId() { + return getIdFromGraphQLId(this.packageEntity.id); }, pipeline() { return this.packageEntity?.pipelines?.nodes[0]; @@ -61,6 +59,9 @@ export default { disabledRow() { return this.packageEntity.status && this.packageEntity.status !== PACKAGE_DEFAULT_STATUS; }, + routerLinkEvent() { + return this.disabledRow ? '' : 'click'; + }, }, i18n: { erroredPackageText: s__('PackageRegistry|Invalid Package: failed metadata extraction'), @@ -73,14 +74,15 @@ export default { <list-item data-qa-selector="package_row" :disabled="disabledRow"> <template #left-primary> <div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0"> - <gl-link - :href="packageLink" + <router-link class="gl-text-body gl-min-w-0" + data-testid="details-link" data-qa-selector="package_link" - :disabled="disabledRow" + :event="routerLinkEvent" + :to="{ name: 'details', params: { id: packageId } }" > <gl-truncate :text="packageEntity.name" /> - </gl-link> + </router-link> <gl-button v-if="showWarningIcon" 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 ab6541e4264..c4d331fa384 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js @@ -74,6 +74,7 @@ export const FETCH_PACKAGE_DETAILS_ERROR_MESSAGE = s__( ); export const DELETE_PACKAGE_SUCCESS_MESSAGE = s__('PackageRegistry|Package deleted successfully'); +export const PACKAGE_REGISTRY_TITLE = __('Package Registry'); export const PACKAGE_ERROR_STATUS = 'ERROR'; export const PACKAGE_DEFAULT_STATUS = 'DEFAULT'; @@ -142,3 +143,9 @@ export const PACKAGE_TYPES = [ export const EMPTY_LIST_HELP_URL = helpPagePath('user/packages/package_registry/index'); export const PACKAGE_HELP_URL = helpPagePath('user/packages/index'); +export const NPM_HELP_PATH = helpPagePath('user/packages/npm_registry/index'); +export const MAVEN_HELP_PATH = helpPagePath('user/packages/maven_repository/index'); +export const CONAN_HELP_PATH = helpPagePath('user/packages/conan_repository/index'); +export const NUGET_HELP_PATH = helpPagePath('user/packages/nuget_repository/index'); +export const PYPI_HELP_PATH = helpPagePath('user/packages/pypi_repository/index'); +export const COMPOSER_HELP_PATH = helpPagePath('user/packages/composer_repository/index'); diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql index 08ea0938a59..c45cbe56e00 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql @@ -7,9 +7,19 @@ query getPackageDetails($id: ID!) { createdAt updatedAt status + canDestroy + npmUrl + mavenUrl + conanUrl + nugetUrl + pypiUrl + pypiSetupUrl + composerUrl + composerConfigRepositoryUrl project { id path + name } tags(first: 10) { nodes { diff --git a/app/assets/javascripts/packages_and_registries/package_registry/index.js b/app/assets/javascripts/packages_and_registries/package_registry/index.js index 7ec931ff9a0..6680e612985 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/index.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/index.js @@ -2,29 +2,59 @@ import Vue from 'vue'; import Translate from '~/vue_shared/translate'; import { apolloProvider } from '~/packages_and_registries/package_registry/graphql/index'; import PackageRegistry from '~/packages_and_registries/package_registry/pages/index.vue'; +import RegistryBreadcrumb from '~/packages_and_registries/shared/components/registry_breadcrumb.vue'; +import { renderBreadcrumb } from '~/packages_and_registries/shared/utils'; import createRouter from './router'; Vue.use(Translate); export default () => { const el = document.getElementById('js-vue-packages-list'); - const { endpoint, resourceId, fullPath, pageType, emptyListIllustration } = el.dataset; - const router = createRouter(endpoint); + const { + endpoint, + resourceId, + fullPath, + pageType, + emptyListIllustration, + npmInstanceUrl, + projectListUrl, + groupListUrl, + } = el.dataset; const isGroupPage = pageType === 'groups'; - return new Vue({ - el, - router, - apolloProvider, - provide: { - resourceId, - fullPath, - emptyListIllustration, - isGroupPage, - }, - render(createElement) { - return createElement(PackageRegistry); + // This is a mini state to help the breadcrumb have the correct name in the details page + const breadCrumbState = Vue.observable({ + name: '', + updateName(value) { + this.name = value; }, }); + + const router = createRouter(endpoint, breadCrumbState); + + const attachMainComponent = () => + new Vue({ + el, + router, + apolloProvider, + provide: { + resourceId, + fullPath, + emptyListIllustration, + isGroupPage, + npmInstanceUrl, + projectListUrl, + groupListUrl, + breadCrumbState, + }, + render(createElement) { + return createElement(PackageRegistry); + }, + }); + + return { + attachBreadcrumb: renderBreadcrumb(router, apolloProvider, RegistryBreadcrumb), + attachMainComponent, + }; }; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.js b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.js deleted file mode 100644 index d94bbd21035..00000000000 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.js +++ /dev/null @@ -1,27 +0,0 @@ -import Vue from 'vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import PackagesApp from '~/packages_and_registries/package_registry/components/details/app.vue'; -import { apolloProvider } from '~/packages_and_registries/package_registry/graphql/index'; -import Translate from '~/vue_shared/translate'; - -Vue.use(Translate); - -export default () => { - const el = document.getElementById('js-vue-packages-detail-new'); - if (!el) { - return null; - } - - const { canDelete, ...datasetOptions } = el.dataset; - return new Vue({ - el, - apolloProvider, - provide: { - canDelete: parseBoolean(canDelete), - ...datasetOptions, - }, - render(createElement) { - return createElement(PackagesApp); - }, - }); -}; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue index d49c1be5202..162b420a784 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue @@ -68,16 +68,7 @@ export default { GlModal: GlModalDirective, }, mixins: [Tracking.mixin()], - inject: [ - 'packageId', - 'projectName', - 'canDelete', - 'svgPath', - 'npmPath', - 'npmHelpPath', - 'projectListUrl', - 'groupListUrl', - ], + inject: ['emptyListIllustration', 'projectListUrl', 'groupListUrl', 'breadCrumbState'], trackingActions: { DELETE_PACKAGE_TRACKING_ACTION, REQUEST_DELETE_PACKAGE_TRACKING_ACTION, @@ -100,7 +91,7 @@ export default { return this.queryVariables; }, update(data) { - return data.package; + return data.package || {}; }, error(error) { createFlash({ @@ -109,22 +100,33 @@ export default { error, }); }, + result() { + this.breadCrumbState.updateName( + `${this.packageEntity?.name} v ${this.packageEntity?.version}`, + ); + }, }, }, computed: { + projectName() { + return this.packageEntity.project?.name; + }, + packageId() { + return this.$route.params.id; + }, queryVariables() { return { id: convertToGraphQLId('Packages::Package', this.packageId), }; }, packageFiles() { - return this.packageEntity?.packageFiles?.nodes; + return this.packageEntity.packageFiles?.nodes; }, isLoading() { return this.$apollo.queries.packageEntity.loading; }, isValidPackage() { - return this.isLoading || Boolean(this.packageEntity?.name); + return this.isLoading || Boolean(this.packageEntity.name); }, tracking() { return { @@ -141,7 +143,7 @@ export default { return this.packageEntity.packageType === PACKAGE_TYPE_NUGET; }, showFiles() { - return this.packageEntity?.packageType !== PACKAGE_TYPE_COMPOSER; + return this.packageEntity.packageType !== PACKAGE_TYPE_COMPOSER; }, }, methods: { @@ -235,13 +237,13 @@ export default { v-if="!isValidPackage" :title="s__('PackageRegistry|Unable to load package')" :description="s__('PackageRegistry|There was a problem fetching the details for this package.')" - :svg-path="svgPath" + :svg-path="emptyListIllustration" /> <div v-else-if="!isLoading" class="packages-app"> <package-title :package-entity="packageEntity"> <template #delete-button> <gl-button - v-if="canDelete" + v-if="packageEntity.canDestroy" v-gl-modal="'delete-modal'" variant="danger" category="primary" @@ -265,6 +267,7 @@ export default { <package-files v-if="showFiles" + :can-delete="packageEntity.canDestroy" :package-files="packageFiles" @download-file="track($options.trackingActions.PULL_PACKAGE)" @delete-file="handleFileDelete" diff --git a/app/assets/javascripts/packages_and_registries/package_registry/router.js b/app/assets/javascripts/packages_and_registries/package_registry/router.js index ea5b740e879..c5ef4f70dd9 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/router.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/router.js @@ -1,10 +1,12 @@ import Vue from 'vue'; import VueRouter from 'vue-router'; import List from '~/packages_and_registries/package_registry/pages/list.vue'; +import Details from '~/packages_and_registries/package_registry/pages/details.vue'; +import { PACKAGE_REGISTRY_TITLE } from '~/packages_and_registries/package_registry/constants'; Vue.use(VueRouter); -export default function createRouter(base) { +export default function createRouter(base, breadCrumbState) { const router = new VueRouter({ base, mode: 'history', @@ -13,9 +15,25 @@ export default function createRouter(base) { name: 'list', path: '/', component: List, + meta: { + nameGenerator: () => PACKAGE_REGISTRY_TITLE, + root: true, + }, + }, + { + name: 'details', + path: '/:id', + component: Details, + meta: { + nameGenerator: () => breadCrumbState.name, + }, }, ], }); + router.afterEach(() => { + breadCrumbState.updateName(''); + }); + return router; } diff --git a/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue b/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue new file mode 100644 index 00000000000..9b2de1a1b84 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue @@ -0,0 +1,80 @@ +<script> +import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; +import UrlSync from '~/vue_shared/components/url_sync.vue'; +import { extractFilterAndSorting, getQueryParams } from '~/packages_and_registries/shared/utils'; + +export default { + components: { RegistrySearch, UrlSync }, + props: { + sortableFields: { + type: Array, + required: true, + }, + defaultOrder: { + type: String, + required: true, + }, + defaultSort: { + type: String, + required: true, + }, + }, + data() { + return { + filters: [], + sorting: { + orderBy: this.defaultOrder, + sort: this.defaultSort, + }, + mountRegistrySearch: false, + }; + }, + computed: { + parsedSorting() { + const cleanOrderBy = this.sorting?.orderBy.replace('_at', ''); + return `${cleanOrderBy}_${this.sorting?.sort}`.toUpperCase(); + }, + }, + mounted() { + const queryParams = getQueryParams(window.document.location.search); + const { sorting, filters } = extractFilterAndSorting(queryParams); + this.updateSorting(sorting); + this.updateFilters(filters); + this.mountRegistrySearch = true; + this.emitUpdate(); + }, + methods: { + updateFilters(newValue) { + this.filters = newValue; + }, + updateSorting(newValue) { + this.sorting = { ...this.sorting, ...newValue }; + }, + updateSortingAndEmitUpdate(newValue) { + this.updateSorting(newValue); + this.emitUpdate(); + }, + emitUpdate() { + this.$emit('update', { sort: this.parsedSorting, filters: this.filters }); + }, + }, +}; +</script> + +<template> + <url-sync> + <template #default="{ updateQuery }"> + <registry-search + v-if="mountRegistrySearch" + :filter="filters" + :sorting="sorting" + :tokens="$options.tokens" + :sortable-fields="sortableFields" + @sorting:changed="updateSortingAndEmitUpdate" + @filter:changed="updateFilters" + @filter:submit="emitUpdate" + @query:changed="updateQuery" + /> + </template> + </url-sync> +</template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/registry_breadcrumb.vue b/app/assets/javascripts/packages_and_registries/shared/components/registry_breadcrumb.vue index e77eda31596..a1e3c06812c 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/registry_breadcrumb.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/registry_breadcrumb.vue @@ -20,8 +20,11 @@ export default { isRootRoute() { return this.$route.name === this.rootRoute.name; }, + detailsRouteName() { + return this.detailsRoute.meta.nameGenerator(); + }, isLoaded() { - return this.isRootRoute || this.$store?.state.imageDetails?.name; + return this.isRootRoute || this.detailsRouteName; }, allCrumbs() { const crumbs = [ @@ -32,7 +35,7 @@ export default { ]; if (!this.isRootRoute) { crumbs.push({ - text: this.detailsRoute.meta.nameGenerator(), + text: this.detailsRouteName, href: this.detailsRoute.meta.path, }); } @@ -45,7 +48,9 @@ export default { <template> <gl-breadcrumb :key="isLoaded" :items="allCrumbs"> <template #separator> - <gl-icon name="angle-right" :size="8" /> + <span class="gl-mx-n5"> + <gl-icon name="angle-right" :size="8" /> + </span> </template> </gl-breadcrumb> </template> diff --git a/app/assets/javascripts/packages_and_registries/shared/utils.js b/app/assets/javascripts/packages_and_registries/shared/utils.js index cf18f655e79..7e963cd0b08 100644 --- a/app/assets/javascripts/packages_and_registries/shared/utils.js +++ b/app/assets/javascripts/packages_and_registries/shared/utils.js @@ -1,3 +1,4 @@ +import Vue from 'vue'; import { queryToObject } from '~/lib/utils/url_utility'; import { FILTERED_SEARCH_TERM } from './constants'; @@ -38,3 +39,37 @@ export const getCommitLink = ({ project_path: projectPath, pipeline = {} }, isGr return `../commit/${pipeline.sha}`; }; + +export const renderBreadcrumb = (router, apolloProvider, RegistryBreadcrumb) => () => { + const breadCrumbEls = document.querySelectorAll('nav .js-breadcrumbs-list li'); + const breadCrumbEl = breadCrumbEls[breadCrumbEls.length - 1]; + const lastCrumb = breadCrumbEl.children[0]; + const crumbs = [lastCrumb]; + const nestedBreadcrumbEl = document.createElement('div'); + breadCrumbEl.replaceChild(nestedBreadcrumbEl, lastCrumb); + return new Vue({ + el: nestedBreadcrumbEl, + router, + apolloProvider, + components: { + RegistryBreadcrumb, + }, + render(createElement) { + // FIXME(@tnir): this is a workaround until the MR gets merged: + // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48115 + const parentEl = breadCrumbEl.parentElement.parentElement; + if (parentEl) { + parentEl.classList.remove('breadcrumbs-container'); + parentEl.classList.add('gl-display-flex'); + parentEl.classList.add('w-100'); + } + // End of FIXME(@tnir) + return createElement('registry-breadcrumb', { + class: breadCrumbEl.className, + props: { + crumbs, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/pages/admin/integrations/edit/index.js b/app/assets/javascripts/pages/admin/integrations/edit/index.js index 8485b460261..c354ed1c142 100644 --- a/app/assets/javascripts/pages/admin/integrations/edit/index.js +++ b/app/assets/javascripts/pages/admin/integrations/edit/index.js @@ -1,7 +1,7 @@ import initIntegrationSettingsForm from '~/integrations/edit'; import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics'; -initIntegrationSettingsForm('.js-integration-settings-form'); +initIntegrationSettingsForm(); const prometheusSettingsSelector = '.js-prometheus-metrics-monitoring'; const prometheusSettingsWrapper = document.querySelector(prometheusSettingsSelector); diff --git a/app/assets/javascripts/pages/admin/labels/edit/index.js b/app/assets/javascripts/pages/admin/labels/edit/index.js index a3b9c43388a..a5eee2857df 100644 --- a/app/assets/javascripts/pages/admin/labels/edit/index.js +++ b/app/assets/javascripts/pages/admin/labels/edit/index.js @@ -1,3 +1,5 @@ import Labels from '~/labels/labels'; +import { initDeleteLabelModal } from '~/labels'; new Labels(); // eslint-disable-line no-new +initDeleteLabelModal(); diff --git a/app/assets/javascripts/pages/admin/runners/edit/index.js b/app/assets/javascripts/pages/admin/runners/edit/index.js new file mode 100644 index 00000000000..ddf135a2732 --- /dev/null +++ b/app/assets/javascripts/pages/admin/runners/edit/index.js @@ -0,0 +1,3 @@ +import { initAdminRunnerEdit } from '~/runner/admin_runner_edit'; + +initAdminRunnerEdit(); diff --git a/app/assets/javascripts/pages/admin/runners/show/index.js b/app/assets/javascripts/pages/admin/runners/show/index.js deleted file mode 100644 index d1853772fda..00000000000 --- a/app/assets/javascripts/pages/admin/runners/show/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import { initRunnerDetail } from '~/runner/runner_details'; - -initRunnerDetail(); diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js index a1e7eb5d3de..cabb1b24ae6 100644 --- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js +++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js @@ -172,8 +172,12 @@ export default class Todos { updateBadges(data) { $(document).trigger('todo:toggle', data.count); - document.querySelector('.js-todos-pending .badge').innerHTML = addDelimiter(data.count); - document.querySelector('.js-todos-done .badge').innerHTML = addDelimiter(data.done_count); + document.querySelector('.js-todos-pending .js-todos-badge').innerHTML = addDelimiter( + data.count, + ); + document.querySelector('.js-todos-done .js-todos-badge').innerHTML = addDelimiter( + data.done_count, + ); } goToTodoUrl(e) { diff --git a/app/assets/javascripts/pages/explore/groups/index.js b/app/assets/javascripts/pages/explore/groups/index.js index 808fcce46df..05078191e5c 100644 --- a/app/assets/javascripts/pages/explore/groups/index.js +++ b/app/assets/javascripts/pages/explore/groups/index.js @@ -1,6 +1,6 @@ -import GroupsList from '~/groups_list'; -import Landing from '~/landing'; -import initGroupsList from '../../../groups'; +import initGroupsList from '~/groups'; +import GroupsList from '~/groups/groups_list'; +import Landing from '~/groups/landing'; function exploreGroups() { new GroupsList(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js index 604da77f60c..f6155b2ab2f 100644 --- a/app/assets/javascripts/pages/groups/edit/index.js +++ b/app/assets/javascripts/pages/groups/edit/index.js @@ -1,20 +1,18 @@ import { GROUP_BADGE } from '~/badges/constants'; -import initConfirmDangerModal from '~/confirm_danger_modal'; import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; import initFilePickers from '~/file_pickers'; import TransferDropdown from '~/groups/transfer_dropdown'; +import setupTransferEdit from '~/groups/transfer_edit'; import groupsSelect from '~/groups_select'; import { initCascadingSettingsLockPopovers } from '~/namespaces/cascading_settings'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; import projectSelect from '~/project_select'; import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; -import setupTransferEdit from '~/transfer_edit'; import initConfirmDanger from '~/init_confirm_danger'; document.addEventListener('DOMContentLoaded', () => { initFilePickers(); - initConfirmDangerModal(); initConfirmDanger(); initSettingsPanels(); dirtySubmitFactory( diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index 966d55e5587..725c38defc3 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -1,6 +1,6 @@ import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; -import issuableInitBulkUpdateSidebar from '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar'; -import { mountIssuablesListApp, mountIssuesListApp } from '~/issues_list'; +import { initBulkUpdateSidebar } from '~/issuable/bulk_update_sidebar'; +import { mountIssuesListApp } from '~/issues/list'; import initManualOrdering from '~/issues/manual_ordering'; import { FILTERED_SEARCH } from '~/filtered_search/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; @@ -13,7 +13,7 @@ if (gon.features?.vueIssuesList) { IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); IssuableFilteredSearchTokenKeys.removeTokensForKeys('release'); - issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX); + initBulkUpdateSidebar(ISSUE_BULK_UPDATE_PREFIX); initFilteredSearch({ page: FILTERED_SEARCH.ISSUES, @@ -23,8 +23,4 @@ if (gon.features?.vueIssuesList) { }); projectSelect(); initManualOrdering(); - - if (gon.features?.vueIssuablesList) { - mountIssuablesListApp(); - } } diff --git a/app/assets/javascripts/pages/groups/labels/edit/index.js b/app/assets/javascripts/pages/groups/labels/edit/index.js index e4e377f62fc..c032321d039 100644 --- a/app/assets/javascripts/pages/groups/labels/edit/index.js +++ b/app/assets/javascripts/pages/groups/labels/edit/index.js @@ -1,4 +1,6 @@ import Labels from 'ee_else_ce/labels/labels'; +import { initDeleteLabelModal } from '~/labels'; // eslint-disable-next-line no-new new Labels(); +initDeleteLabelModal(); diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js index cb38ee1c6e0..de28f027126 100644 --- a/app/assets/javascripts/pages/groups/merge_requests/index.js +++ b/app/assets/javascripts/pages/groups/merge_requests/index.js @@ -1,6 +1,6 @@ import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; -import issuableInitBulkUpdateSidebar from '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar'; +import { initBulkUpdateSidebar } from '~/issuable/bulk_update_sidebar'; import { FILTERED_SEARCH } from '~/filtered_search/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import projectSelect from '~/project_select'; @@ -8,7 +8,7 @@ import projectSelect from '~/project_select'; const ISSUABLE_BULK_UPDATE_PREFIX = 'merge_request_'; addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); -issuableInitBulkUpdateSidebar.init(ISSUABLE_BULK_UPDATE_PREFIX); +initBulkUpdateSidebar(ISSUABLE_BULK_UPDATE_PREFIX); initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js index 7b0418e1ad5..702b152d25a 100644 --- a/app/assets/javascripts/pages/groups/new/index.js +++ b/app/assets/javascripts/pages/groups/new/index.js @@ -15,7 +15,7 @@ initFilePickers(); new Group(); // eslint-disable-line no-new function initNewGroupCreation(el) { - const { hasErrors } = el.dataset; + const { hasErrors, verificationRequired, verificationFormUrl, subscriptionsUrl } = el.dataset; const props = { hasErrors: parseBoolean(hasErrors), @@ -23,6 +23,11 @@ function initNewGroupCreation(el) { return new Vue({ el, + provide: { + verificationRequired: parseBoolean(verificationRequired), + verificationFormUrl, + subscriptionsUrl, + }, render(h) { return h(NewGroupCreationApp, { props }); }, diff --git a/app/assets/javascripts/pages/groups/packages/index.js b/app/assets/javascripts/pages/groups/packages/index.js new file mode 100644 index 00000000000..cbe08565cfa --- /dev/null +++ b/app/assets/javascripts/pages/groups/packages/index.js @@ -0,0 +1,8 @@ +import packageApp from '~/packages_and_registries/package_registry/index'; + +const app = packageApp(); + +if (app) { + app.attachBreadcrumb(); + app.attachMainComponent(); +} diff --git a/app/assets/javascripts/pages/groups/packages/index/index.js b/app/assets/javascripts/pages/groups/packages/index/index.js deleted file mode 100644 index 174973a9fad..00000000000 --- a/app/assets/javascripts/pages/groups/packages/index/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import packageApp from '~/packages_and_registries/package_registry/index'; - -packageApp(); diff --git a/app/assets/javascripts/pages/groups/settings/access_tokens/index.js b/app/assets/javascripts/pages/groups/settings/access_tokens/index.js new file mode 100644 index 00000000000..dc1bb88bf4b --- /dev/null +++ b/app/assets/javascripts/pages/groups/settings/access_tokens/index.js @@ -0,0 +1,3 @@ +import { initExpiresAtField } from '~/access_tokens'; + +initExpiresAtField(); diff --git a/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js b/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js index 8485b460261..c354ed1c142 100644 --- a/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js +++ b/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js @@ -1,7 +1,7 @@ import initIntegrationSettingsForm from '~/integrations/edit'; import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics'; -initIntegrationSettingsForm('.js-integration-settings-form'); +initIntegrationSettingsForm(); const prometheusSettingsSelector = '.js-prometheus-metrics-monitoring'; const prometheusSettingsWrapper = document.querySelector(prometheusSettingsSelector); diff --git a/app/assets/javascripts/pages/help/index/index.js b/app/assets/javascripts/pages/help/index/index.js index 736add8dca3..a8e67c57307 100644 --- a/app/assets/javascripts/pages/help/index/index.js +++ b/app/assets/javascripts/pages/help/index/index.js @@ -1,6 +1,5 @@ -import $ from 'jquery'; import docs from '~/docs/docs_bundle'; -import VersionCheckImage from '~/version_check_image'; +import initGitlabVersionCheck from '~/gitlab_version_check'; docs(); -VersionCheckImage.bindErrorEvent($('img.js-version-status-badge')); +initGitlabVersionCheck(); diff --git a/app/assets/javascripts/pages/profiles/keys/index.js b/app/assets/javascripts/pages/profiles/keys/index.js index 1b291d9509d..6b12604c76b 100644 --- a/app/assets/javascripts/pages/profiles/keys/index.js +++ b/app/assets/javascripts/pages/profiles/keys/index.js @@ -7,11 +7,13 @@ function initSshKeyValidation() { const input = document.querySelector('.js-add-ssh-key-validation-input'); if (!input) return; + const supportedAlgorithms = JSON.parse(input.dataset.supportedAlgorithms); const warning = document.querySelector('.js-add-ssh-key-validation-warning'); const originalSubmit = input.form.querySelector('.js-add-ssh-key-validation-original-submit'); const confirmSubmit = warning.querySelector('.js-add-ssh-key-validation-confirm-submit'); const addSshKeyValidation = new AddSshKeyValidation( + supportedAlgorithms, input, warning, originalSubmit, diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index b365e039191..2fc9a111405 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; import TableOfContents from '~/blob/components/table_contents.vue'; import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue'; import { BlobViewer, initAuxiliaryViewer } from '~/blob/viewer/index'; @@ -12,11 +13,14 @@ import BlobContentViewer from '~/repository/components/blob_content_viewer.vue'; import '~/sourcegraph/load'; Vue.use(VueApollo); +Vue.use(VueRouter); const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), }); +const router = new VueRouter({ mode: 'history' }); + const viewBlobEl = document.querySelector('#js-view-blob-app'); if (viewBlobEl) { @@ -25,6 +29,7 @@ if (viewBlobEl) { // eslint-disable-next-line no-new new Vue({ el: viewBlobEl, + router, apolloProvider, provide: { targetBranch, @@ -41,6 +46,7 @@ if (viewBlobEl) { }); initAuxiliaryViewer(); + initBlob(); } else { new BlobViewer(); // eslint-disable-line no-new initBlob(); diff --git a/app/assets/javascripts/pages/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js index 97dc76908af..d279c4cbb08 100644 --- a/app/assets/javascripts/pages/projects/branches/index/index.js +++ b/app/assets/javascripts/pages/projects/branches/index/index.js @@ -1,13 +1,11 @@ import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior'; import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner'; import BranchSortDropdown from '~/branches/branch_sort_dropdown'; -import DeleteModal from '~/branches/branches_delete_modal'; import initDiverganceGraph from '~/branches/divergence_graph'; import initDeleteBranchButton from '~/branches/init_delete_branch_button'; import initDeleteBranchModal from '~/branches/init_delete_branch_modal'; AjaxLoadingSpinner.init(); -new DeleteModal(); // eslint-disable-line no-new const { divergingCountsEndpoint, defaultBranch } = document.querySelector( '.js-branch-list', diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js index 100ca5b36d9..c0eb2a8fd77 100644 --- a/app/assets/javascripts/pages/projects/edit/index.js +++ b/app/assets/javascripts/pages/projects/edit/index.js @@ -1,5 +1,4 @@ import { PROJECT_BADGE } from '~/badges/constants'; -import initLegacyConfirmDangerModal from '~/confirm_danger_modal'; import initConfirmDanger from '~/init_confirm_danger'; import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; import initFilePickers from '~/file_pickers'; @@ -15,7 +14,6 @@ import initProjectPermissionsSettings from '../shared/permissions'; import initProjectLoadingSpinner from '../shared/save_project_loader'; initFilePickers(); -initLegacyConfirmDangerModal(); initConfirmDanger(); initSettingsPanels(); initProjectDeleteButton(); diff --git a/app/assets/javascripts/pages/projects/find_file/show/index.js b/app/assets/javascripts/pages/projects/find_file/show/index.js index a8225167c6b..f47888f0cb8 100644 --- a/app/assets/javascripts/pages/projects/find_file/show/index.js +++ b/app/assets/javascripts/pages/projects/find_file/show/index.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import ShortcutsFindFile from '~/behaviors/shortcuts/shortcuts_find_file'; -import ProjectFindFile from '~/project_find_file'; +import ProjectFindFile from '~/projects/project_find_file'; const findElement = document.querySelector('.js-file-finder'); const projectFindFile = new ProjectFindFile($('.file-finder-holder'), { diff --git a/app/assets/javascripts/pages/projects/imports/show/index.js b/app/assets/javascripts/pages/projects/imports/show/index.js index 8397826f8eb..21d07e04ddc 100644 --- a/app/assets/javascripts/pages/projects/imports/show/index.js +++ b/app/assets/javascripts/pages/projects/imports/show/index.js @@ -1,3 +1,3 @@ -import ProjectImport from '~/project_import'; +import ProjectImport from '~/projects/project_import'; new ProjectImport(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/incidents/show/index.js b/app/assets/javascripts/pages/projects/incidents/show/index.js index 4633eaef8f9..5a8cfcf8462 100644 --- a/app/assets/javascripts/pages/projects/incidents/show/index.js +++ b/app/assets/javascripts/pages/projects/incidents/show/index.js @@ -1,6 +1,6 @@ +import { initShow } from '~/issues'; import initRelatedIssues from '~/related_issues'; import initSidebarBundle from '~/sidebar/sidebar_bundle'; -import initShow from '~/issues/show'; initShow(); initSidebarBundle(); diff --git a/app/assets/javascripts/pages/projects/init_blob.js b/app/assets/javascripts/pages/projects/init_blob.js index 06aba866ccf..7db34816cfe 100644 --- a/app/assets/javascripts/pages/projects/init_blob.js +++ b/app/assets/javascripts/pages/projects/init_blob.js @@ -2,8 +2,8 @@ import ShortcutsBlob from '~/behaviors/shortcuts/shortcuts_blob'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import BlobForkSuggestion from '~/blob/blob_fork_suggestion'; import BlobLinePermalinkUpdater from '~/blob/blob_line_permalink_updater'; +import LineHighlighter from '~/blob/line_highlighter'; import initBlobBundle from '~/blob_edit/blob_bundle'; -import LineHighlighter from '~/line_highlighter'; export default () => { new LineHighlighter(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/issues/edit/index.js b/app/assets/javascripts/pages/projects/issues/edit/index.js index aa00d1f58bd..06dcd2c2d94 100644 --- a/app/assets/javascripts/pages/projects/issues/edit/index.js +++ b/app/assets/javascripts/pages/projects/issues/edit/index.js @@ -1,3 +1,3 @@ -import initForm from 'ee_else_ce/issues/form'; +import { initForm } from 'ee_else_ce/issues'; initForm(); diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index e937713044c..44b1d5277d1 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -1,8 +1,8 @@ import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import { initCsvImportExportButtons, initIssuableByEmail } from '~/issuable'; -import issuableInitBulkUpdateSidebar from '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar'; -import { mountIssuablesListApp, mountIssuesListApp, mountJiraIssuesListApp } from '~/issues_list'; +import { initBulkUpdateSidebar, initIssueStatusSelect } from '~/issuable/bulk_update_sidebar'; +import { mountIssuesListApp, mountJiraIssuesListApp } from '~/issues/list'; import initManualOrdering from '~/issues/manual_ordering'; import { FILTERED_SEARCH } from '~/filtered_search/constants'; import { ISSUABLE_INDEX } from '~/issuable/constants'; @@ -20,16 +20,13 @@ if (gon.features?.vueIssuesList) { useDefaultState: true, }); - issuableInitBulkUpdateSidebar.init(ISSUABLE_INDEX.ISSUE); + initBulkUpdateSidebar(ISSUABLE_INDEX.ISSUE); + initIssueStatusSelect(); new UsersSelect(); // eslint-disable-line no-new initCsvImportExportButtons(); initIssuableByEmail(); initManualOrdering(); - - if (gon.features?.vueIssuablesList) { - mountIssuablesListApp(); - } } new ShortcutsNavigation(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js index aa00d1f58bd..06dcd2c2d94 100644 --- a/app/assets/javascripts/pages/projects/issues/new/index.js +++ b/app/assets/javascripts/pages/projects/issues/new/index.js @@ -1,3 +1,3 @@ -import initForm from 'ee_else_ce/issues/form'; +import { initForm } from 'ee_else_ce/issues'; initForm(); diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/index.js b/app/assets/javascripts/pages/projects/issues/service_desk/index.js index 69639d17f8a..7dd128fedb9 100644 --- a/app/assets/javascripts/pages/projects/issues/service_desk/index.js +++ b/app/assets/javascripts/pages/projects/issues/service_desk/index.js @@ -1,8 +1,3 @@ -import { mountIssuablesListApp } from '~/issues_list'; -import { initFilteredSearchServiceDesk } from '~/issues/init_filtered_search_service_desk'; +import { initFilteredSearchServiceDesk } from '~/issues'; initFilteredSearchServiceDesk(); - -if (gon.features?.vueIssuablesList) { - mountIssuablesListApp(); -} diff --git a/app/assets/javascripts/pages/projects/issues/show/index.js b/app/assets/javascripts/pages/projects/issues/show/index.js index d0b1942f2a4..46a34c025b6 100644 --- a/app/assets/javascripts/pages/projects/issues/show/index.js +++ b/app/assets/javascripts/pages/projects/issues/show/index.js @@ -1,7 +1,7 @@ +import { initShow } from '~/issues'; import { store } from '~/notes/stores'; import initRelatedIssues from '~/related_issues'; import initSidebarBundle from '~/sidebar/sidebar_bundle'; -import initShow from '~/issues/show'; initShow(); initSidebarBundle(store); diff --git a/app/assets/javascripts/pages/projects/labels/edit/index.js b/app/assets/javascripts/pages/projects/labels/edit/index.js index c4d7af39767..cb554e3d4da 100644 --- a/app/assets/javascripts/pages/projects/labels/edit/index.js +++ b/app/assets/javascripts/pages/projects/labels/edit/index.js @@ -1,3 +1,5 @@ import Labels from 'ee_else_ce/labels/labels'; +import { initDeleteLabelModal } from '~/labels'; new Labels(); // eslint-disable-line no-new +initDeleteLabelModal(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js index acd1731a700..e284e7b2c5e 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js @@ -2,13 +2,14 @@ import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; import { initCsvImportExportButtons, initIssuableByEmail } from '~/issuable'; -import issuableInitBulkUpdateSidebar from '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar'; +import { initBulkUpdateSidebar, initIssueStatusSelect } from '~/issuable/bulk_update_sidebar'; import { FILTERED_SEARCH } from '~/filtered_search/constants'; import { ISSUABLE_INDEX } from '~/issuable/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import UsersSelect from '~/users_select'; -issuableInitBulkUpdateSidebar.init(ISSUABLE_INDEX.MERGE_REQUEST); +initBulkUpdateSidebar(ISSUABLE_INDEX.MERGE_REQUEST); +initIssueStatusSelect(); addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); IssuableFilteredSearchTokenKeys.removeTokensForKeys('iteration'); diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js index d89b4d0e0a3..5d830872ed9 100644 --- a/app/assets/javascripts/pages/projects/new/index.js +++ b/app/assets/javascripts/pages/projects/new/index.js @@ -1,5 +1,5 @@ import { initNewProjectCreation, initNewProjectUrlSelect } from '~/projects/new'; -import initProjectVisibilitySelector from '~/project_visibility'; +import initProjectVisibilitySelector from '~/projects/project_visibility'; import initProjectNew from '~/projects/project_new'; initProjectVisibilitySelector(); diff --git a/app/assets/javascripts/pages/projects/packages/packages/index.js b/app/assets/javascripts/pages/projects/packages/packages/index.js new file mode 100644 index 00000000000..cbe08565cfa --- /dev/null +++ b/app/assets/javascripts/pages/projects/packages/packages/index.js @@ -0,0 +1,8 @@ +import packageApp from '~/packages_and_registries/package_registry/index'; + +const app = packageApp(); + +if (app) { + app.attachBreadcrumb(); + app.attachMainComponent(); +} diff --git a/app/assets/javascripts/pages/projects/packages/packages/index/index.js b/app/assets/javascripts/pages/projects/packages/packages/index/index.js deleted file mode 100644 index 174973a9fad..00000000000 --- a/app/assets/javascripts/pages/projects/packages/packages/index/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import packageApp from '~/packages_and_registries/package_registry/index'; - -packageApp(); diff --git a/app/assets/javascripts/pages/projects/packages/packages/show/index.js b/app/assets/javascripts/pages/projects/packages/packages/show/index.js deleted file mode 100644 index 2dee87985cb..00000000000 --- a/app/assets/javascripts/pages/projects/packages/packages/show/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import initPackageDetails from '~/packages_and_registries/package_registry/pages/details'; - -initPackageDetails(); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js index e92b9b30fa4..277d2e0d30a 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js @@ -1,6 +1,6 @@ import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -const defaultTimezone = { name: 'UTC', offset: 0 }; +const defaultTimezone = { identifier: 'Etc/UTC', name: 'UTC', offset: 0 }; const defaults = { $inputEl: null, $dropdownEl: null, @@ -70,7 +70,7 @@ export default class TimezoneDropdown { setDropdownValue(timezone) { this.$dropdownToggle.text(this.displayFormat(timezone)); - this.$input.val(timezone.name); + this.$input.val(timezone.identifier); } handleDropdownChange({ selectedObj, e }) { diff --git a/app/assets/javascripts/pages/projects/services/edit/index.js b/app/assets/javascripts/pages/projects/services/edit/index.js index a2b18d86240..2048d3dfc37 100644 --- a/app/assets/javascripts/pages/projects/services/edit/index.js +++ b/app/assets/javascripts/pages/projects/services/edit/index.js @@ -2,7 +2,7 @@ import initIntegrationSettingsForm from '~/integrations/edit'; import PrometheusAlerts from '~/prometheus_alerts'; import CustomMetrics from '~/prometheus_metrics/custom_metrics'; -initIntegrationSettingsForm('.js-integration-settings-form'); +initIntegrationSettingsForm(); const prometheusSettingsSelector = '.js-prometheus-metrics-monitoring'; const prometheusSettingsWrapper = document.querySelector(prometheusSettingsSelector); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index 384ee1f5034..d5e00f54e91 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -1,6 +1,6 @@ <script> -import { GlIcon, GlSprintf, GlLink, GlFormCheckbox, GlToggle } from '@gitlab/ui'; - +import { GlButton, GlIcon, GlSprintf, GlLink, GlFormCheckbox, GlToggle } from '@gitlab/ui'; +import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue'; import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin'; import { __, s__ } from '~/locale'; import { @@ -41,16 +41,19 @@ export default { pucWarningHelpText: s__( 'ProjectSettings|Highlight the usage of hidden unicode characters. These have innocent uses for right-to-left languages, but can also be used in potential exploits.', ), + confirmButtonText: __('Save changes'), }, components: { projectFeatureSetting, projectSettingRow, + GlButton, GlIcon, GlSprintf, GlLink, GlFormCheckbox, GlToggle, + ConfirmDanger, }, mixins: [settingsMixin], @@ -163,6 +166,15 @@ export default { required: false, default: '', }, + confirmationPhrase: { + type: String, + required: true, + }, + showVisibilityConfirmModal: { + type: Boolean, + required: false, + default: false, + }, }, data() { const defaults = { @@ -274,6 +286,12 @@ export default { cveIdRequestIsDisabled() { return this.visibilityLevel !== visibilityOptions.PUBLIC; }, + isVisibilityReduced() { + return ( + this.showVisibilityConfirmModal && + this.visibilityLevel < this.currentSettings.visibilityLevel + ); + }, }, watch: { @@ -774,5 +792,23 @@ export default { <template #help>{{ $options.i18n.pucWarningHelpText }}</template> </gl-form-checkbox> </project-setting-row> + <confirm-danger + v-if="isVisibilityReduced" + button-variant="confirm" + :disabled="false" + :phrase="confirmationPhrase" + :button-text="$options.i18n.confirmButtonText" + data-testid="project-features-save-button" + @confirm="$emit('confirm')" + /> + <gl-button + v-else + type="submit" + variant="confirm" + data-testid="project-features-save-button" + data-qa-selector="visibility_features_permissions_save_button" + > + {{ $options.i18n.confirmButtonText }} + </gl-button> </div> </template> diff --git a/app/assets/javascripts/pages/projects/shared/permissions/index.js b/app/assets/javascripts/pages/projects/shared/permissions/index.js index d7bae44e96e..de8b1cc400e 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/index.js +++ b/app/assets/javascripts/pages/projects/shared/permissions/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; import settingsPanel from './components/settings_panel.vue'; export default function initProjectPermissionsSettings() { @@ -6,8 +7,36 @@ export default function initProjectPermissionsSettings() { const componentPropsEl = document.querySelector('.js-project-permissions-form-data'); const componentProps = JSON.parse(componentPropsEl.innerHTML); + const { + targetFormId, + additionalInformation, + confirmDangerMessage, + confirmButtonText, + showVisibilityConfirmModal, + htmlConfirmationMessage, + phrase: confirmationPhrase, + } = mountPoint.dataset; + return new Vue({ el: mountPoint, - render: (createElement) => createElement(settingsPanel, { props: { ...componentProps } }), + provide: { + additionalInformation, + confirmDangerMessage, + confirmButtonText, + htmlConfirmationMessage: parseBoolean(htmlConfirmationMessage), + }, + render: (createElement) => + createElement(settingsPanel, { + props: { + ...componentProps, + confirmationPhrase, + showVisibilityConfirmModal: parseBoolean(showVisibilityConfirmModal), + }, + on: { + confirm: () => { + if (targetFormId) document.getElementById(targetFormId)?.submit(); + }, + }, + }), }); } diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index 31d69a731fe..71c6773c176 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -1,16 +1,16 @@ import initTree from 'ee_else_ce/repository'; import Activities from '~/activities'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -import { BlobViewer } from '~/blob/viewer/index'; +import { BlobViewer } from '~/blob/viewer'; import { initUploadForm } from '~/blob_edit/blob_bundle'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import leaveByUrl from '~/namespaces/leave_by_url'; import initVueNotificationsDropdown from '~/notifications'; +import Star from '~/projects/star'; import { initUploadFileTrigger } from '~/projects/upload_file'; import initReadMore from '~/read_more'; import UserCallout from '~/user_callout'; -import Star from '../../../star'; initReadMore(); new Star(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js index ae605edeaf0..8bbe81a9ed5 100644 --- a/app/assets/javascripts/pages/registrations/new/index.js +++ b/app/assets/javascripts/pages/registrations/new/index.js @@ -1,3 +1,5 @@ +import { trackNewRegistrations } from '~/google_tag_manager'; + import NoEmojiValidator from '~/emoji/no_emoji_validator'; import LengthValidator from '~/pages/sessions/new/length_validator'; import UsernameValidator from '~/pages/sessions/new/username_validator'; @@ -5,3 +7,5 @@ import UsernameValidator from '~/pages/sessions/new/username_validator'; new UsernameValidator(); // eslint-disable-line no-new new LengthValidator(); // eslint-disable-line no-new new NoEmojiValidator(); // eslint-disable-line no-new + +trackNewRegistrations(); diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue index b29e9455755..c28de88554a 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -596,7 +596,9 @@ export default { :disabled="disableSubmitButton" >{{ submitButtonText }}</gl-button > - <gl-button :href="cancelFormPath" class="float-right">{{ $options.i18n.cancel }}</gl-button> + <gl-button data-testid="wiki-cancel-button" :href="cancelFormPath" class="float-right">{{ + $options.i18n.cancel + }}</gl-button> </div> </gl-form> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue index 9f82d4a5395..ca78f194a82 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue @@ -142,6 +142,7 @@ export default { class="js-no-auto-disable" category="primary" variant="confirm" + data-qa-selector="commit_changes_button" :disabled="submitDisabled" :loading="isSaving" > diff --git a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue index 92fa411d5af..bfbf24c6b13 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue +++ b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue @@ -15,12 +15,10 @@ export default { onCiConfigUpdate(content) { this.$emit('updateCiConfig', content); }, - registerCiSchema() { + registerCiSchema({ detail: { instance } }) { if (this.glFeatures.schemaLinting) { - const editorInstance = this.$refs.editor.getEditor(); - - editorInstance.use({ definition: CiSchemaExtension }); - editorInstance.registerCiSchema(); + instance.use({ definition: CiSchemaExtension }); + instance.registerCiSchema(); } }, }, @@ -33,7 +31,7 @@ export default { ref="editor" :file-name="ciConfigPath" v-bind="$attrs" - @[$options.readyEvent]="registerCiSchema" + @[$options.readyEvent]="registerCiSchema($event)" @input="onCiConfigUpdate" v-on="$listeners" /> diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue index 16ad648afca..72b492a5877 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue @@ -153,7 +153,9 @@ export default { <span class="gl-font-weight-bold"> <gl-sprintf :message="$options.i18n.pipelineInfo"> <template #id="{ content }"> - <span data-testid="pipeline-id"> {{ content }}{{ pipelineId }} </span> + <span data-testid="pipeline-id" data-qa-selector="pipeline_id_content"> + {{ content }}{{ pipelineId }} + </span> </template> <template #status>{{ status.text }}</template> <template #commit> diff --git a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue index 833d784f940..23f1592cac1 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue @@ -5,6 +5,7 @@ import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.qu import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { EDITOR_APP_STATUS_EMPTY, + EDITOR_APP_STATUS_LINT_UNAVAILABLE, EDITOR_APP_STATUS_LOADING, EDITOR_APP_STATUS_VALID, } from '../../constants'; @@ -17,6 +18,7 @@ export const i18n = { loading: s__('Pipelines|Validating GitLab CI configuration…'), invalid: s__('Pipelines|This GitLab CI configuration is invalid.'), invalidWithReason: s__('Pipelines|This GitLab CI configuration is invalid: %{reason}.'), + unavailableValidation: s__('Pipelines|Configuration validation currently not available.'), valid: s__('Pipelines|This GitLab CI configuration is valid.'), }; @@ -29,6 +31,9 @@ export default { TooltipOnTruncate, }, inject: { + lintUnavailableHelpPagePath: { + default: '', + }, ymlHelpPagePath: { default: '', }, @@ -49,9 +54,15 @@ export default { }, }, computed: { + helpPath() { + return this.isLintUnavailable ? this.lintUnavailableHelpPagePath : this.ymlHelpPagePath; + }, isEmpty() { return this.appStatus === EDITOR_APP_STATUS_EMPTY; }, + isLintUnavailable() { + return this.appStatus === EDITOR_APP_STATUS_LINT_UNAVAILABLE; + }, isLoading() { return this.appStatus === EDITOR_APP_STATUS_LOADING; }, @@ -62,6 +73,8 @@ export default { switch (this.appStatus) { case EDITOR_APP_STATUS_EMPTY: return 'check'; + case EDITOR_APP_STATUS_LINT_UNAVAILABLE: + return 'time-out'; case EDITOR_APP_STATUS_VALID: return 'check'; default: @@ -74,6 +87,8 @@ export default { switch (this.appStatus) { case EDITOR_APP_STATUS_EMPTY: return this.$options.i18n.empty; + case EDITOR_APP_STATUS_LINT_UNAVAILABLE: + return this.$options.i18n.unavailableValidation; case EDITOR_APP_STATUS_VALID: return this.$options.i18n.valid; default: @@ -96,10 +111,13 @@ export default { <span v-else class="gl-display-inline-flex gl-white-space-nowrap gl-max-w-full"> <tooltip-on-truncate :title="message" class="gl-text-truncate"> - <gl-icon :name="icon" /> <span data-testid="validationMsg">{{ message }}</span> + <gl-icon :name="icon" /> + <span data-qa-selector="validation_message_content" data-testid="validationMsg"> + {{ message }} + </span> </tooltip-on-truncate> <span v-if="!isEmpty" class="gl-flex-shrink-0 gl-pl-2"> - <gl-link data-testid="learnMoreLink" :href="ymlHelpPagePath"> + <gl-link data-testid="learnMoreLink" :href="helpPath"> {{ $options.i18n.learnMore }} </gl-link> </span> diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue index 3f50a1225d8..c75b1d4bb11 100644 --- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue @@ -11,6 +11,7 @@ import { EDITOR_APP_STATUS_INVALID, EDITOR_APP_STATUS_LOADING, EDITOR_APP_STATUS_VALID, + EDITOR_APP_STATUS_LINT_UNAVAILABLE, LINT_TAB, MERGED_TAB, TAB_QUERY_PARAM, @@ -106,6 +107,9 @@ export default { isInvalid() { return this.appStatus === EDITOR_APP_STATUS_INVALID; }, + isLintUnavailable() { + return this.appStatus === EDITOR_APP_STATUS_LINT_UNAVAILABLE; + }, isValid() { return this.appStatus === EDITOR_APP_STATUS_VALID; }, @@ -142,6 +146,7 @@ export default { <template> <gl-tabs class="file-editor gl-mb-3" + data-qa-selector="file_editor_container" :query-param-name="$options.query.TAB_QUERY_PARAM" sync-active-tab-with-query-params > @@ -166,6 +171,7 @@ export default { :empty-message="$options.i18n.empty.visualization" :is-empty="isEmpty" :is-invalid="isInvalid" + :is-unavailable="isLintUnavailable" :keep-component-mounted="false" :title="$options.i18n.tabGraph" lazy @@ -179,6 +185,7 @@ export default { class="gl-mb-3" :empty-message="$options.i18n.empty.lint" :is-empty="isEmpty" + :is-unavailable="isLintUnavailable" :title="$options.i18n.tabLint" data-testid="lint-tab" @click="setCurrentTab($options.tabConstants.LINT_TAB)" @@ -192,6 +199,7 @@ export default { :keep-component-mounted="false" :is-empty="isEmpty" :is-invalid="isInvalid" + :is-unavailable="isLintUnavailable" :title="$options.i18n.tabMergedYaml" lazy data-testid="merged-tab" diff --git a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue b/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue index 7c032441a04..673599da085 100644 --- a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue +++ b/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue @@ -42,6 +42,9 @@ import { __, s__ } from '~/locale'; export default { i18n: { invalid: __('Your CI/CD configuration syntax is invalid. View Lint tab for more details.'), + unavailable: __( + "We're experiencing difficulties and this tab content is currently unavailable.", + ), }, components: { GlAlert, @@ -66,14 +69,14 @@ export default { isEmpty: { type: Boolean, required: false, - default: null, + default: false, }, isInvalid: { type: Boolean, required: false, - default: null, + default: false, }, - lazy: { + isUnavailable: { type: Boolean, required: false, default: false, @@ -83,6 +86,11 @@ export default { required: false, default: true, }, + lazy: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -109,6 +117,9 @@ export default { <template> <gl-tab :lazy="isLazy" v-bind="$attrs" v-on="$listeners"> <gl-alert v-if="isEmpty" variant="tip">{{ emptyMessage }}</gl-alert> + <gl-alert v-else-if="isUnavailable" variant="danger" :dismissible="false"> + {{ $options.i18n.unavailable }}</gl-alert + > <gl-alert v-else-if="isInvalid" variant="danger">{{ $options.i18n.invalid }}</gl-alert> <template v-else> <slot v-for="slot in slots" :name="slot"></slot> diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js index a2eaeeef286..bc79b0742e7 100644 --- a/app/assets/javascripts/pipeline_editor/constants.js +++ b/app/assets/javascripts/pipeline_editor/constants.js @@ -6,12 +6,14 @@ export const CI_CONFIG_STATUS_VALID = 'VALID'; // represent the global state of the pipeline editor app. export const EDITOR_APP_STATUS_EMPTY = 'EMPTY'; export const EDITOR_APP_STATUS_INVALID = CI_CONFIG_STATUS_INVALID; +export const EDITOR_APP_STATUS_LINT_UNAVAILABLE = 'LINT_DOWN'; export const EDITOR_APP_STATUS_LOADING = 'LOADING'; export const EDITOR_APP_STATUS_VALID = CI_CONFIG_STATUS_VALID; export const EDITOR_APP_VALID_STATUSES = [ EDITOR_APP_STATUS_EMPTY, EDITOR_APP_STATUS_INVALID, + EDITOR_APP_STATUS_LINT_UNAVAILABLE, EDITOR_APP_STATUS_LOADING, EDITOR_APP_STATUS_VALID, ]; diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index ee93e327b76..04f91cb3d1e 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -37,6 +37,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { emptyStateIllustrationPath, helpPaths, lintHelpPagePath, + lintUnavailableHelpPagePath, needsHelpPagePath, newMergeRequestPath, pipelinePagePath, @@ -124,6 +125,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { emptyStateIllustrationPath, helpPaths, lintHelpPagePath, + lintUnavailableHelpPagePath, needsHelpPagePath, newMergeRequestPath, pipelinePagePath, diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index e397054f06a..90f48195c5e 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -12,8 +12,9 @@ import PipelineEditorMessages from './components/ui/pipeline_editor_messages.vue import { COMMIT_SHA_POLL_INTERVAL, EDITOR_APP_STATUS_EMPTY, - EDITOR_APP_VALID_STATUSES, EDITOR_APP_STATUS_LOADING, + EDITOR_APP_STATUS_LINT_UNAVAILABLE, + EDITOR_APP_VALID_STATUSES, LOAD_FAILURE_UNKNOWN, STARTER_TEMPLATE_NAME, } from './constants'; @@ -51,6 +52,7 @@ export default { failureReasons: [], initialCiFileContent: '', isFetchingCommitSha: false, + isLintUnavailable: false, isNewCiConfigFile: false, lastCommittedContent: '', shouldSkipStartScreen: false, @@ -147,10 +149,19 @@ export default { return { ...ciConfig, stages }; }, result({ data }) { - this.setAppStatus(data?.ciConfig?.status); + if (data?.ciConfig?.status) { + this.setAppStatus(data.ciConfig.status); + if (this.isLintUnavailable) { + this.isLintUnavailable = false; + } + } }, - error(err) { - this.reportFailure(LOAD_FAILURE_UNKNOWN, [String(err)]); + error() { + // We are not using `reportFailure` here because we don't + // need to bring attention to the linter being down. We let + // the user work on their file and if they look at their + // lint status, they will notice that the service is down + this.isLintUnavailable = true; }, watchLoading(isLoading) { if (isLoading) { @@ -247,6 +258,13 @@ export default { this.setAppStatus(EDITOR_APP_STATUS_EMPTY); } }, + isLintUnavailable(flag) { + if (flag) { + // We cannot set this status directly in the `error` + // hook otherwise we get an infinite loop caused by apollo. + this.setAppStatus(EDITOR_APP_STATUS_LINT_UNAVAILABLE); + } + }, }, mounted() { this.loadTemplateFromURL(); @@ -269,14 +287,10 @@ export default { await this.$apollo.queries.initialCiFileContent.refetch(); }, reportFailure(type, reasons = []) { - const isCurrentFailure = this.failureType === type && this.failureReasons[0] === reasons[0]; - - if (!isCurrentFailure) { - this.showFailure = true; - this.failureType = type; - this.failureReasons = reasons; - window.scrollTo({ top: 0, behavior: 'smooth' }); - } + this.showFailure = true; + this.failureType = type; + this.failureReasons = reasons; + window.scrollTo({ top: 0, behavior: 'smooth' }); }, reportSuccess(type) { window.scrollTo({ top: 0, behavior: 'smooth' }); @@ -289,7 +303,10 @@ export default { }, setAppStatus(appStatus) { if (EDITOR_APP_VALID_STATUSES.includes(appStatus)) { - this.$apollo.mutate({ mutation: updateAppStatus, variables: { appStatus } }); + this.$apollo.mutate({ + mutation: updateAppStatus, + variables: { appStatus }, + }); } }, setNewEmptyCiConfigFile() { diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue index 8e8f31a4acc..96680080f0c 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue @@ -90,7 +90,7 @@ export default { </script> <template> - <div class="gl-pr-9 gl-transition-medium gl-w-full"> + <div class="gl-pr-10 gl-transition-medium gl-w-full"> <gl-modal v-if="showSwitchBranchModal" visible diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index 4db6a3c9fd8..8088858f381 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -212,7 +212,9 @@ export default { </script> <template> <div class="js-pipeline-header-container"> - <gl-alert v-if="hasError" :variant="failure.variant">{{ failure.text }}</gl-alert> + <gl-alert v-if="hasError" :variant="failure.variant" :dismissible="false">{{ + failure.text + }}</gl-alert> <ci-header v-if="shouldRenderContent" :status="pipeline.detailedStatus" diff --git a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue index ffac8206b58..e11073aee33 100644 --- a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue +++ b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue @@ -112,7 +112,7 @@ export default { </gl-skeleton-loader> </div> - <jobs-table v-else :jobs="jobs" :table-fields="$options.fields" /> + <jobs-table v-else :jobs="jobs" :table-fields="$options.fields" data-testid="jobs-tab-table" /> <gl-intersection-observer v-if="jobsPageInfo.hasNextPage" @appear="fetchMoreJobs"> <gl-loading-icon v-if="$apollo.loading" size="md" /> diff --git a/app/assets/javascripts/pipelines/components/parsing_utils.js b/app/assets/javascripts/pipelines/components/parsing_utils.js index fa7330ce890..cae4e11c13f 100644 --- a/app/assets/javascripts/pipelines/components/parsing_utils.js +++ b/app/assets/javascripts/pipelines/components/parsing_utils.js @@ -1,5 +1,6 @@ import { memoize } from 'lodash'; import { createNodeDict } from '../utils'; +import { EXPLICIT_NEEDS_PROPERTY, NEEDS_PROPERTY } from '../constants'; import { createSankey } from './dag/drawing_utils'; /* @@ -15,12 +16,14 @@ const deduplicate = (item, itemIndex, arr) => { return foundIdx === itemIndex; }; -export const makeLinksFromNodes = (nodes, nodeDict) => { +export const makeLinksFromNodes = (nodes, nodeDict, { needsKey = NEEDS_PROPERTY } = {}) => { const constantLinkValue = 10; // all links are the same weight return nodes .map(({ jobs, name: groupName }) => - jobs.map(({ needs = [] }) => - needs.reduce((acc, needed) => { + jobs.map((job) => { + const needs = job[needsKey] || []; + + return needs.reduce((acc, needed) => { // It's possible that we have an optional job, which // is being needed by another job. In that scenario, // the needed job doesn't exist, so we don't want to @@ -34,8 +37,8 @@ export const makeLinksFromNodes = (nodes, nodeDict) => { } return acc; - }, []), - ), + }, []); + }), ) .flat(2); }; @@ -76,9 +79,9 @@ export const filterByAncestors = (links, nodeDict) => return !allAncestors.includes(source); }); -export const parseData = (nodes) => { - const nodeDict = createNodeDict(nodes); - const allLinks = makeLinksFromNodes(nodes, nodeDict); +export const parseData = (nodes, { needsKey = NEEDS_PROPERTY } = {}) => { + const nodeDict = createNodeDict(nodes, { needsKey }); + const allLinks = makeLinksFromNodes(nodes, nodeDict, { needsKey }); const filteredLinks = allLinks.filter(deduplicate); const links = filterByAncestors(filteredLinks, nodeDict); @@ -123,7 +126,8 @@ export const removeOrphanNodes = (sankeyfiedNodes) => { export const listByLayers = ({ stages }) => { const arrayOfJobs = stages.flatMap(({ groups }) => groups); const parsedData = parseData(arrayOfJobs); - const dataWithLayers = createSankey()(parsedData); + const explicitParsedData = parseData(arrayOfJobs, { needsKey: EXPLICIT_NEEDS_PROPERTY }); + const dataWithLayers = createSankey()(explicitParsedData); const pipelineLayers = dataWithLayers.nodes.reduce((acc, { layer, name }) => { /* sort groups by layer */ diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue index 64210576b29..8daf85e2b2e 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue @@ -132,6 +132,7 @@ export default { :ref="$options.CONTAINER_REF" class="gl-bg-gray-10 gl-overflow-auto" data-testid="graph-container" + data-qa-selector="pipeline_graph_container" > <links-layer :pipeline-data="pipelineStages" @@ -147,7 +148,10 @@ export default { :key="`${stage.name}-${index}`" class="gl-flex-direction-column" > - <div class="gl-display-flex gl-align-items-center gl-w-full gl-px-9 gl-py-4 gl-mb-5"> + <div + class="gl-display-flex gl-align-items-center gl-w-full gl-px-9 gl-py-4 gl-mb-5" + data-qa-selector="stage_container" + > <stage-name :stage-name="stage.name" /> </div> <div :class="$options.jobWrapperClasses"> @@ -158,6 +162,7 @@ export default { :pipeline-id="$options.PIPELINE_ID" :is-hovered="highlightedJob === group.name" :is-faded-out="isFadedOut(group.name)" + data-qa-selector="job_container" @on-mouse-enter="setHoveredJob" @on-mouse-leave="removeHoveredJob" /> diff --git a/app/assets/javascripts/pipelines/components/unwrapping_utils.js b/app/assets/javascripts/pipelines/components/unwrapping_utils.js index 2d24beb8323..d42a11c3aba 100644 --- a/app/assets/javascripts/pipelines/components/unwrapping_utils.js +++ b/app/assets/javascripts/pipelines/components/unwrapping_utils.js @@ -1,4 +1,5 @@ import { reportToSentry } from '../utils'; +import { EXPLICIT_NEEDS_PROPERTY, NEEDS_PROPERTY } from '../constants'; const unwrapGroups = (stages) => { return stages.map((stage, idx) => { @@ -27,12 +28,16 @@ const unwrapNodesWithName = (jobArray, prop, field = 'name') => { } return jobArray.map((job) => { - return { ...job, [prop]: job[prop].nodes.map((item) => item[field] || '') }; + if (job[prop]) { + return { ...job, [prop]: job[prop].nodes.map((item) => item[field] || '') }; + } + return job; }); }; const unwrapJobWithNeeds = (denodedJobArray) => { - return unwrapNodesWithName(denodedJobArray, 'needs'); + const explicitNeedsUnwrapped = unwrapNodesWithName(denodedJobArray, EXPLICIT_NEEDS_PROPERTY); + return unwrapNodesWithName(explicitNeedsUnwrapped, NEEDS_PROPERTY); }; const unwrapStagesWithNeedsAndLookup = (denodedStages) => { diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index d123f7a203c..410fc7b82cd 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -7,6 +7,8 @@ export const ANY_TRIGGER_AUTHOR = 'Any'; export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref', 'status', 'source']; export const FILTER_TAG_IDENTIFIER = 'tag'; export const SCHEDULE_ORIGIN = 'schedule'; +export const NEEDS_PROPERTY = 'needs'; +export const EXPLICIT_NEEDS_PROPERTY = 'previousStageJobsOrNeeds'; export const TestStatus = { FAILED: 'failed', diff --git a/app/assets/javascripts/pipelines/graphql/fragmentTypes.json b/app/assets/javascripts/pipelines/graphql/fragmentTypes.json new file mode 100644 index 00000000000..4601b74b5c1 --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/fragmentTypes.json @@ -0,0 +1 @@ +{"__schema":{"types":[{"kind":"UNION","name":"JobNeedUnion","possibleTypes":[{"name":"CiBuildNeed"},{"name":"CiJob"}]}]}}
\ No newline at end of file diff --git a/app/assets/javascripts/pipelines/pipeline_shared_client.js b/app/assets/javascripts/pipelines/pipeline_shared_client.js index c3be487caae..84276588d6a 100644 --- a/app/assets/javascripts/pipelines/pipeline_shared_client.js +++ b/app/assets/javascripts/pipelines/pipeline_shared_client.js @@ -1,10 +1,19 @@ import VueApollo from 'vue-apollo'; +import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import createDefaultClient from '~/lib/graphql'; +import introspectionQueryResultData from './graphql/fragmentTypes.json'; + +export const fragmentMatcher = new IntrospectionFragmentMatcher({ + introspectionQueryResultData, +}); export const apolloProvider = new VueApollo({ defaultClient: createDefaultClient( {}, { + cacheConfig: { + fragmentMatcher, + }, useGet: true, }, ), diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js index e28eb74fb1b..f6e1c8b7412 100644 --- a/app/assets/javascripts/pipelines/utils.js +++ b/app/assets/javascripts/pipelines/utils.js @@ -1,6 +1,6 @@ import * as Sentry from '@sentry/browser'; import { pickBy } from 'lodash'; -import { SUPPORTED_FILTER_PARAMETERS } from './constants'; +import { SUPPORTED_FILTER_PARAMETERS, NEEDS_PROPERTY } from './constants'; /* The following functions are the main engine in transforming the data as @@ -35,11 +35,11 @@ import { SUPPORTED_FILTER_PARAMETERS } from './constants'; 10 -> value (constant) */ -export const createNodeDict = (nodes) => { +export const createNodeDict = (nodes, { needsKey = NEEDS_PROPERTY } = {}) => { return nodes.reduce((acc, node) => { const newNode = { ...node, - needs: node.jobs.map((job) => job.needs || []).flat(), + needs: node.jobs.map((job) => job[needsKey] || []).flat(), }; if (node.size > 1) { diff --git a/app/assets/javascripts/profile/add_ssh_key_validation.js b/app/assets/javascripts/profile/add_ssh_key_validation.js index 5c78de7ffb0..628dd159db8 100644 --- a/app/assets/javascripts/profile/add_ssh_key_validation.js +++ b/app/assets/javascripts/profile/add_ssh_key_validation.js @@ -1,8 +1,17 @@ export default class AddSshKeyValidation { - constructor(inputElement, warningElement, originalSubmitElement, confirmSubmitElement) { + constructor( + supportedAlgorithms, + inputElement, + warningElement, + originalSubmitElement, + confirmSubmitElement, + ) { this.inputElement = inputElement; this.form = inputElement.form; + this.supportedAlgorithms = supportedAlgorithms; + this.publicKeyRegExp = new RegExp(`^(${this.supportedAlgorithms.join('|')})`); + this.warningElement = warningElement; this.originalSubmitElement = originalSubmitElement; @@ -23,7 +32,7 @@ export default class AddSshKeyValidation { } submit(event) { - this.isValid = AddSshKeyValidation.isPublicKey(this.inputElement.value); + this.isValid = this.isPublicKey(this.inputElement.value); if (this.isValid) return true; @@ -37,7 +46,7 @@ export default class AddSshKeyValidation { this.originalSubmitElement.classList.toggle('hide', isVisible); } - static isPublicKey(value) { - return /^(ssh|ecdsa-sha2)-/.test(value); + isPublicKey(value) { + return this.publicKeyRegExp.test(value); } } diff --git a/app/assets/javascripts/project_select_combo_button.js b/app/assets/javascripts/project_select_combo_button.js index fd45d643ecc..09dbf2cee04 100644 --- a/app/assets/javascripts/project_select_combo_button.js +++ b/app/assets/javascripts/project_select_combo_button.js @@ -1,11 +1,12 @@ import $ from 'jquery'; +import { sprintf, __ } from '~/locale'; import AccessorUtilities from './lib/utils/accessor'; import { loadCSSFile } from './lib/utils/css_utils'; export default class ProjectSelectComboButton { constructor(select) { this.projectSelectInput = $(select); - this.newItemBtn = $('.new-project-item-link'); + this.newItemBtn = $('.js-new-project-item-link'); this.resourceType = this.newItemBtn.data('type'); this.resourceLabel = this.newItemBtn.data('label'); this.formattedText = this.deriveTextVariants(); @@ -80,9 +81,18 @@ export default class ProjectSelectComboButton { setNewItemBtnAttributes(project) { if (project) { this.newItemBtn.attr('href', project.url); - this.newItemBtn.text(`${this.formattedText.defaultTextPrefix} in ${project.name}`); + this.newItemBtn.text( + sprintf(__('New %{type} in %{project}'), { + type: this.resourceLabel, + project: project.name, + }), + ); } else { - this.newItemBtn.text(`Select project to create ${this.formattedText.presetTextSuffix}`); + this.newItemBtn.text( + sprintf(__('Select project to create %{type}'), { + type: this.formattedText.presetTextSuffix, + }), + ); } } @@ -99,15 +109,12 @@ export default class ProjectSelectComboButton { } deriveTextVariants() { - const defaultTextPrefix = this.resourceLabel; - // the trailing slice call depluralizes each of these strings (e.g. new-issues -> new-issue) const localStorageItemType = `new-${this.resourceType.split('_').join('-').slice(0, -1)}`; const presetTextSuffix = this.resourceType.split('_').join(' ').slice(0, -1); return { localStorageItemType, // new-issue / new-merge-request - defaultTextPrefix, // New issue / New merge request presetTextSuffix, // issue / merge request }; } diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/projects/project_find_file.js index d295c06928f..d295c06928f 100644 --- a/app/assets/javascripts/project_find_file.js +++ b/app/assets/javascripts/projects/project_find_file.js diff --git a/app/assets/javascripts/project_import.js b/app/assets/javascripts/projects/project_import.js index a51a2a2242f..27a218f1f52 100644 --- a/app/assets/javascripts/project_import.js +++ b/app/assets/javascripts/projects/project_import.js @@ -1,4 +1,4 @@ -import { visitUrl } from './lib/utils/url_utility'; +import { visitUrl } from '~/lib/utils/url_utility'; export default function projectImport() { setTimeout(() => { diff --git a/app/assets/javascripts/project_visibility.js b/app/assets/javascripts/projects/project_visibility.js index 1b57a69d464..c962554c9f4 100644 --- a/app/assets/javascripts/project_visibility.js +++ b/app/assets/javascripts/projects/project_visibility.js @@ -1,4 +1,6 @@ import $ from 'jquery'; +import { escape } from 'lodash'; +import { __, sprintf } from '~/locale'; import eventHub from '~/projects/new/event_hub'; // Values are from lib/gitlab/visibility_level.rb @@ -25,10 +27,21 @@ function setVisibilityOptions({ name, visibility, showPath, editPath }) { if (reason) { const optionTitle = option.querySelector('.option-title'); const optionName = optionTitle ? optionTitle.innerText.toLowerCase() : ''; - reason.innerHTML = `This project cannot be ${optionName} because the visibility of - <a href="${showPath}">${name}</a> is ${visibility}. To make this project - ${optionName}, you must first <a href="${editPath}">change the visibility</a> - of the parent group.`; + reason.innerHTML = sprintf( + __( + 'This project cannot be %{visibilityLevel} because the visibility of %{openShowLink}%{name}%{closeShowLink} is %{visibility}. To make this project %{visibilityLevel}, you must first %{openEditLink}change the visibility%{closeEditLink} of the parent group.', + ), + { + visibilityLevel: optionName, + name: escape(name), + visibility, + openShowLink: `<a href="${showPath}">`, + closeShowLink: '</a>', + openEditLink: `<a href="${editPath}">`, + closeEditLink: '</a>', + }, + false, + ); } } else { option.classList.remove('disabled'); diff --git a/app/assets/javascripts/star.js b/app/assets/javascripts/projects/star.js index 7cba445d9b1..578e22ca25d 100644 --- a/app/assets/javascripts/star.js +++ b/app/assets/javascripts/projects/star.js @@ -1,8 +1,8 @@ import $ from 'jquery'; -import createFlash from './flash'; -import axios from './lib/utils/axios_utils'; -import { spriteIcon } from './lib/utils/common_utils'; -import { __, s__ } from './locale'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { spriteIcon } from '~/lib/utils/common_utils'; +import { __, s__ } from '~/locale'; export default class Star { constructor(container = '.project-home-panel') { diff --git a/app/assets/javascripts/related_issues/components/related_issues_list.vue b/app/assets/javascripts/related_issues/components/related_issues_list.vue index 58138655241..8b39851405e 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_list.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue @@ -110,7 +110,7 @@ export default { v-for="issue in relatedIssues" :key="issue.id" :class="{ - 'user-can-drag': canReorder, + 'gl-cursor-grab': canReorder, 'sortable-row': canReorder, 'card card-slim': canReorder, }" diff --git a/app/assets/javascripts/repository/components/blob_button_group.vue b/app/assets/javascripts/repository/components/blob_button_group.vue index 6f540bf8ece..857795c71b0 100644 --- a/app/assets/javascripts/repository/components/blob_button_group.vue +++ b/app/assets/javascripts/repository/components/blob_button_group.vue @@ -1,5 +1,5 @@ <script> -import { GlButtonGroup, GlButton, GlModalDirective } from '@gitlab/ui'; +import { GlButtonGroup, GlButton } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { sprintf, __ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -20,9 +20,6 @@ export default { DeleteBlobModal, LockButton: () => import('ee_component/repository/components/lock_button.vue'), }, - directives: { - GlModal: GlModalDirective, - }, mixins: [getRefMixin, glFeatureFlagMixin()], inject: { targetBranch: { @@ -73,6 +70,10 @@ export default { type: Boolean, required: true, }, + showForkSuggestion: { + type: Boolean, + required: true, + }, }, computed: { replaceModalId() { @@ -91,6 +92,16 @@ export default { return this.canLock ? 'lock_button' : 'disabled_lock_button'; }, }, + methods: { + showModal(modalId) { + if (this.showForkSuggestion) { + this.$emit('fork'); + return; + } + + this.$refs[modalId].show(); + }, + }, }; </script> @@ -107,14 +118,15 @@ export default { data-testid="lock" :data-qa-selector="lockBtnQASelector" /> - <gl-button v-gl-modal="replaceModalId" data-testid="replace"> + <gl-button data-testid="replace" @click="showModal(replaceModalId)"> {{ $options.i18n.replace }} </gl-button> - <gl-button v-gl-modal="deleteModalId" data-testid="delete"> + <gl-button data-testid="delete" @click="showModal(deleteModalId)"> {{ $options.i18n.delete }} </gl-button> </gl-button-group> <upload-blob-modal + :ref="replaceModalId" :modal-id="replaceModalId" :modal-title="replaceModalTitle" :commit-message="replaceModalTitle" @@ -126,6 +138,7 @@ export default { :primary-btn-text="$options.i18n.replacePrimaryBtnText" /> <delete-blob-modal + :ref="deleteModalId" :modal-id="deleteModalId" :modal-title="deleteModalTitle" :delete-path="deletePath" diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index f3fa4526999..9368d7e6058 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -105,8 +105,10 @@ export default { forkAndEditPath: '', ideForkAndEditPath: '', storedExternally: false, + externalStorage: '', canModifyBlob: false, canCurrentUserPushToBranch: false, + archived: false, rawPath: '', externalStorageUrl: '', replacePath: '', @@ -166,7 +168,7 @@ export default { return pushCode && downloadCode; }, pathLockedByUser() { - const pathLock = this.project.pathLocks.nodes.find((node) => node.path === this.path); + const pathLock = this.project?.pathLocks?.nodes.find((node) => node.path === this.path); return pathLock ? pathLock.user : null; }, @@ -249,6 +251,7 @@ export default { > <template #actions> <blob-edit + v-if="!blobInfo.archived" :show-edit-button="!isBinaryFileType" :edit-path="blobInfo.editBlobPath" :web-ide-path="blobInfo.ideEditPath" @@ -268,7 +271,7 @@ export default { </gl-button> <blob-button-group - v-if="isLoggedIn" + v-if="isLoggedIn && !blobInfo.archived" :path="path" :name="blobInfo.name" :replace-path="blobInfo.replacePath" @@ -279,6 +282,8 @@ export default { :project-path="projectPath" :is-locked="Boolean(pathLockedByUser)" :can-lock="canLock" + :show-fork-suggestion="showForkSuggestion" + @fork="setForkTarget('ide')" /> </template> </blob-header> @@ -289,6 +294,7 @@ export default { /> <blob-content v-if="!blobViewer" + class="js-syntax-highlight" :rich-viewer="legacyRichViewer" :blob="blobInfo" :content="legacySimpleViewer" diff --git a/app/assets/javascripts/repository/components/blob_controls.vue b/app/assets/javascripts/repository/components/blob_controls.vue new file mode 100644 index 00000000000..3223ed92fe2 --- /dev/null +++ b/app/assets/javascripts/repository/components/blob_controls.vue @@ -0,0 +1,119 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; +import createFlash from '~/flash'; +import getRefMixin from '~/repository/mixins/get_ref'; +import initSourcegraph from '~/sourcegraph'; +import { updateElementsVisibility } from '../utils/dom'; +import blobControlsQuery from '../queries/blob_controls.query.graphql'; + +export default { + i18n: { + findFile: __('Find file'), + blame: __('Blame'), + history: __('History'), + permalink: __('Permalink'), + errorMessage: __('An error occurred while loading the blob controls.'), + }, + buttonClassList: 'gl-sm-w-auto gl-w-full gl-sm-mt-0 gl-mt-3', + components: { + GlButton, + }, + mixins: [getRefMixin], + apollo: { + project: { + query: blobControlsQuery, + variables() { + return { + projectPath: this.projectPath, + filePath: this.filePath, + ref: this.ref, + }; + }, + skip() { + return !this.filePath; + }, + error() { + createFlash({ message: this.$options.i18n.errorMessage }); + }, + }, + }, + props: { + projectPath: { + type: String, + required: true, + }, + }, + data() { + return { + project: { + repository: { + blobs: { + nodes: [ + { + findFilePath: null, + blamePath: null, + historyPath: null, + permalinkPath: null, + storedExternally: null, + externalStorage: null, + }, + ], + }, + }, + }, + }; + }, + computed: { + filePath() { + return this.$route.params.path; + }, + showBlobControls() { + return this.filePath && this.$route.name === 'blobPathDecoded'; + }, + blobInfo() { + return this.project?.repository?.blobs?.nodes[0] || {}; + }, + showBlameButton() { + return !this.blobInfo.storedExternally && this.blobInfo.externalStorage !== 'lfs'; + }, + }, + watch: { + showBlobControls(shouldShow) { + updateElementsVisibility('.tree-controls', !shouldShow); + }, + blobInfo() { + initSourcegraph(); + }, + }, +}; +</script> + +<template> + <div v-if="showBlobControls"> + <gl-button data-testid="find" :href="blobInfo.findFilePath" :class="$options.buttonClassList"> + {{ $options.i18n.findFile }} + </gl-button> + <gl-button + v-if="showBlameButton" + data-testid="blame" + :href="blobInfo.blamePath" + :class="$options.buttonClassList" + > + {{ $options.i18n.blame }} + </gl-button> + + <gl-button data-testid="history" :href="blobInfo.historyPath" :class="$options.buttonClassList"> + {{ $options.i18n.history }} + </gl-button> + + <gl-button + data-testid="permalink" + :href="blobInfo.permalinkPath" + :class="$options.buttonClassList" + class="js-data-file-blob-permalink-url" + > + {{ $options.i18n.permalink }} + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/repository/components/blob_edit.vue b/app/assets/javascripts/repository/components/blob_edit.vue index fd377ba1b81..69e2bd563c9 100644 --- a/app/assets/javascripts/repository/components/blob_edit.vue +++ b/app/assets/javascripts/repository/components/blob_edit.vue @@ -50,6 +50,7 @@ export default { :web-ide-url="webIdePath" :needs-to-fork="needsToFork" :is-blob="true" + disable-fork-modal @edit="onEdit" /> <div v-else> diff --git a/app/assets/javascripts/repository/components/delete_blob_modal.vue b/app/assets/javascripts/repository/components/delete_blob_modal.vue index 0d3dc06c2c8..f3c9aea36f1 100644 --- a/app/assets/javascripts/repository/components/delete_blob_modal.vue +++ b/app/assets/javascripts/repository/components/delete_blob_modal.vue @@ -146,6 +146,9 @@ export default { /* eslint-enable dot-notation */ }, methods: { + show() { + this.$refs[this.modalId].show(); + }, submitForm(e) { e.preventDefault(); // Prevent modal from closing this.form.showValidation = true; @@ -164,6 +167,7 @@ export default { <template> <gl-modal + :ref="modalId" v-bind="$attrs" data-testid="modal-delete" :modal-id="modalId" diff --git a/app/assets/javascripts/repository/components/fork_suggestion.vue b/app/assets/javascripts/repository/components/fork_suggestion.vue index c266bea319b..471f1dad2e3 100644 --- a/app/assets/javascripts/repository/components/fork_suggestion.vue +++ b/app/assets/javascripts/repository/components/fork_suggestion.vue @@ -32,6 +32,7 @@ export default { class="gl-mr-3" category="secondary" variant="confirm" + data-method="post" :href="forkPath" data-testid="fork" > diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue index c6e461b10e0..dc5a031c9f3 100644 --- a/app/assets/javascripts/repository/components/preview/index.vue +++ b/app/assets/javascripts/repository/components/preview/index.vue @@ -47,6 +47,9 @@ export default { } }, }, + safeHtmlConfig: { + ADD_TAGS: ['copy-code'], + }, }; </script> @@ -62,7 +65,11 @@ export default { </div> <div class="blob-viewer" data-qa-selector="blob_viewer_content" itemprop="about"> <gl-loading-icon v-if="loading > 0" size="md" color="dark" class="my-4 mx-auto" /> - <div v-else-if="readme" ref="readme" v-safe-html="readme.html"></div> + <div + v-else-if="readme" + ref="readme" + v-safe-html:[$options.safeHtmlConfig]="readme.html" + ></div> </div> </article> </template> diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue index b56c9ce5247..7fcaf772aac 100644 --- a/app/assets/javascripts/repository/components/upload_blob_modal.vue +++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue @@ -136,6 +136,9 @@ export default { }, }, methods: { + show() { + this.$refs[this.modalId].show(); + }, setFile(file) { this.file = file; @@ -206,6 +209,7 @@ export default { <template> <gl-form> <gl-modal + :ref="modalId" :modal-id="modalId" :title="modalTitle" :action-primary="primaryOptions" diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 197b19387cf..120c32caefd 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -9,6 +9,7 @@ import App from './components/app.vue'; import Breadcrumbs from './components/breadcrumbs.vue'; import DirectoryDownloadLinks from './components/directory_download_links.vue'; import LastCommit from './components/last_commit.vue'; +import BlobControls from './components/blob_controls.vue'; import apolloProvider from './graphql'; import commitsQuery from './queries/commits.query.graphql'; import projectPathQuery from './queries/project_path.query.graphql'; @@ -71,8 +72,26 @@ export default function setupVueRepositoryList() { }, }); + const initBlobControlsApp = () => + new Vue({ + el: document.getElementById('js-blob-controls'), + router, + apolloProvider, + render(h) { + return h(BlobControls, { + props: { + projectPath, + }, + }); + }, + }); + initLastCommitApp(); + if (gon.features.refactorBlobViewer) { + initBlobControlsApp(); + } + router.afterEach(({ params: { path } }) => { setTitle(path, ref, fullName); }); @@ -144,7 +163,7 @@ export default function setupVueRepositoryList() { }`, // Ideally passing this class to `props` should work // But it doesn't work here. :( - class: 'btn btn-default btn-md gl-button ml-sm-0', + class: 'btn btn-default btn-md gl-button', }, }, [__('History')], diff --git a/app/assets/javascripts/repository/queries/blob_controls.query.graphql b/app/assets/javascripts/repository/queries/blob_controls.query.graphql new file mode 100644 index 00000000000..fc1cf5f254b --- /dev/null +++ b/app/assets/javascripts/repository/queries/blob_controls.query.graphql @@ -0,0 +1,18 @@ +query getBlobControls($projectPath: ID!, $filePath: String!, $ref: String!) { + project(fullPath: $projectPath) { + id + repository { + blobs(paths: [$filePath], ref: $ref) { + nodes { + id + findFilePath + blamePath + historyPath + permalinkPath + storedExternally + externalStorage + } + } + } + } +} diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql index 45d1ba80917..ae20a0f0bc4 100644 --- a/app/assets/javascripts/repository/queries/blob_info.query.graphql +++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql @@ -1,22 +1,14 @@ +#import "ee_else_ce/repository/queries/path_locks.fragment.graphql" + query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) { project(fullPath: $projectPath) { - id userPermissions { pushCode downloadCode createMergeRequestIn forkProject } - pathLocks { - nodes { - id - path - user { - id - username - } - } - } + ...ProjectPathLocksFragment repository { empty blobs(paths: [$filePath], ref: $ref) { @@ -35,7 +27,9 @@ query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) { ideForkAndEditPath canModifyBlob canCurrentUserPushToBranch + archived storedExternally + externalStorage rawPath replacePath pipelineEditorPath diff --git a/app/assets/javascripts/repository/queries/path_locks.fragment.graphql b/app/assets/javascripts/repository/queries/path_locks.fragment.graphql new file mode 100644 index 00000000000..868a513362d --- /dev/null +++ b/app/assets/javascripts/repository/queries/path_locks.fragment.graphql @@ -0,0 +1,3 @@ +fragment ProjectPathLocksFragment on Project { + id +} diff --git a/app/assets/javascripts/runner/runner_details/runner_details_app.vue b/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue index 6557a7834e7..4d2ca9b0c58 100644 --- a/app/assets/javascripts/runner/runner_details/runner_details_app.vue +++ b/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue @@ -1,20 +1,17 @@ <script> -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { sprintf } from '~/locale'; -import RunnerTypeAlert from '../components/runner_type_alert.vue'; -import RunnerTypeBadge from '../components/runner_type_badge.vue'; +import RunnerHeader from '../components/runner_header.vue'; import RunnerUpdateForm from '../components/runner_update_form.vue'; -import { I18N_DETAILS_TITLE, I18N_FETCH_ERROR } from '../constants'; +import { I18N_FETCH_ERROR } from '../constants'; import getRunnerQuery from '../graphql/get_runner.query.graphql'; import { captureException } from '../sentry_utils'; export default { - name: 'RunnerDetailsApp', + name: 'AdminRunnerEditApp', components: { - RunnerTypeAlert, - RunnerTypeBadge, + RunnerHeader, RunnerUpdateForm, }, props: { @@ -37,17 +34,12 @@ export default { }; }, error(error) { - createFlash({ message: I18N_FETCH_ERROR }); + createAlert({ message: I18N_FETCH_ERROR }); this.reportToSentry(error); }, }, }, - computed: { - pageTitle() { - return sprintf(I18N_DETAILS_TITLE, { runner_id: this.runnerId }); - }, - }, errorCaptured(error) { this.reportToSentry(error); }, @@ -60,12 +52,7 @@ export default { </script> <template> <div> - <h2 class="page-title"> - {{ pageTitle }} <runner-type-badge v-if="runner" :type="runner.runnerType" /> - </h2> - - <runner-type-alert v-if="runner" :type="runner.runnerType" /> - + <runner-header v-if="runner" :runner="runner" /> <runner-update-form :runner="runner" class="gl-my-5" /> </div> </template> diff --git a/app/assets/javascripts/runner/runner_details/index.js b/app/assets/javascripts/runner/admin_runner_edit/index.js index db8f239a3c3..adb420f9963 100644 --- a/app/assets/javascripts/runner/runner_details/index.js +++ b/app/assets/javascripts/runner/admin_runner_edit/index.js @@ -1,11 +1,11 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import RunnerDetailsApp from './runner_details_app.vue'; +import AdminRunnerEditApp from './admin_runner_edit_app.vue'; Vue.use(VueApollo); -export const initRunnerDetail = (selector = '#js-runner-details') => { +export const initAdminRunnerEdit = (selector = '#js-admin-runner-edit') => { const el = document.querySelector(selector); if (!el) { @@ -22,7 +22,7 @@ export const initRunnerDetail = (selector = '#js-runner-details') => { el, apolloProvider, render(h) { - return h(RunnerDetailsApp, { + return h(AdminRunnerEditApp, { props: { runnerId, }, diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue index f8220553db6..bb2bac531a7 100644 --- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue @@ -1,14 +1,15 @@ <script> import { GlBadge, GlLink } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; import { updateHistory } from '~/lib/utils/url_utility'; +import { formatNumber } from '~/locale'; import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; import RunnerList from '../components/runner_list.vue'; import RunnerName from '../components/runner_name.vue'; -import RunnerOnlineStat from '../components/stat/runner_online_stat.vue'; +import RunnerStats from '../components/stat/runner_stats.vue'; import RunnerPagination from '../components/runner_pagination.vue'; import RunnerTypeTabs from '../components/runner_type_tabs.vue'; @@ -19,9 +20,13 @@ import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE, + STATUS_ONLINE, + STATUS_OFFLINE, + STATUS_STALE, I18N_FETCH_ERROR, } from '../constants'; import getRunnersQuery from '../graphql/get_runners.query.graphql'; +import getRunnersCountQuery from '../graphql/get_runners_count.query.graphql'; import { fromUrlQueryToSearch, fromSearchToUrl, @@ -29,6 +34,17 @@ import { } from '../runner_search_utils'; import { captureException } from '../sentry_utils'; +const runnersCountSmartQuery = { + query: getRunnersCountQuery, + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + update(data) { + return data?.runners?.count; + }, + error(error) { + this.reportToSentry(error); + }, +}; + export default { name: 'AdminRunnersApp', components: { @@ -38,7 +54,7 @@ export default { RunnerFilteredSearchBar, RunnerList, RunnerName, - RunnerOnlineStat, + RunnerStats, RunnerPagination, RunnerTypeTabs, }, @@ -47,26 +63,6 @@ export default { type: String, required: true, }, - activeRunnersCount: { - type: String, - required: true, - }, - allRunnersCount: { - type: String, - required: true, - }, - instanceRunnersCount: { - type: String, - required: true, - }, - groupRunnersCount: { - type: String, - required: true, - }, - projectRunnersCount: { - type: String, - required: true, - }, }, data() { return { @@ -95,16 +91,78 @@ export default { }; }, error(error) { - createFlash({ message: I18N_FETCH_ERROR }); + createAlert({ message: I18N_FETCH_ERROR }); this.reportToSentry(error); }, }, + allRunnersCount: { + ...runnersCountSmartQuery, + variables() { + return this.countVariables; + }, + }, + instanceRunnersCount: { + ...runnersCountSmartQuery, + variables() { + return { + ...this.countVariables, + type: INSTANCE_TYPE, + }; + }, + }, + groupRunnersCount: { + ...runnersCountSmartQuery, + variables() { + return { + ...this.countVariables, + type: GROUP_TYPE, + }; + }, + }, + projectRunnersCount: { + ...runnersCountSmartQuery, + variables() { + return { + ...this.countVariables, + type: PROJECT_TYPE, + }; + }, + }, + onlineRunnersTotal: { + ...runnersCountSmartQuery, + variables() { + return { + status: STATUS_ONLINE, + }; + }, + }, + offlineRunnersTotal: { + ...runnersCountSmartQuery, + variables() { + return { + status: STATUS_OFFLINE, + }; + }, + }, + staleRunnersTotal: { + ...runnersCountSmartQuery, + variables() { + return { + status: STATUS_STALE, + }; + }, + }, }, computed: { variables() { return fromSearchToVariables(this.search); }, + countVariables() { + // Exclude pagination variables, leave only filters variables + const { sort, before, last, after, first, ...countVariables } = this.variables; + return countVariables; + }, runnersLoading() { return this.$apollo.queries.runners.loading; }, @@ -125,7 +183,7 @@ export default { search: { deep: true, handler() { - // TODO Implement back button reponse using onpopstate + // TODO Implement back button response using onpopstate updateHistory({ url: fromSearchToUrl(this.search), title: document.title, @@ -138,18 +196,27 @@ export default { }, methods: { tabCount({ runnerType }) { + let count; switch (runnerType) { case null: - return this.allRunnersCount; + count = this.allRunnersCount; + break; case INSTANCE_TYPE: - return this.instanceRunnersCount; + count = this.instanceRunnersCount; + break; case GROUP_TYPE: - return this.groupRunnersCount; + count = this.groupRunnersCount; + break; case PROJECT_TYPE: - return this.projectRunnersCount; + count = this.projectRunnersCount; + break; default: return null; } + if (typeof count === 'number') { + return formatNumber(count); + } + return ''; }, reportToSentry(error) { captureException({ error, component: this.$options.name }); @@ -161,7 +228,11 @@ export default { </script> <template> <div> - <runner-online-stat class="gl-py-6 gl-px-5" :value="activeRunnersCount" /> + <runner-stats + :online-runners-count="onlineRunnersTotal" + :offline-runners-count="offlineRunnersTotal" + :stale-runners-count="staleRunnersTotal" + /> <div class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0" diff --git a/app/assets/javascripts/runner/admin_runners/index.js b/app/assets/javascripts/runner/admin_runners/index.js index 62da6cbfa2b..3b8a8fe9cd1 100644 --- a/app/assets/javascripts/runner/admin_runners/index.js +++ b/app/assets/javascripts/runner/admin_runners/index.js @@ -2,6 +2,8 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { updateOutdatedUrl } from '~/runner/runner_search_utils'; import AdminRunnersApp from './admin_runners_app.vue'; Vue.use(GlToast); @@ -14,18 +16,16 @@ export const initAdminRunners = (selector = '#js-admin-runners') => { return null; } - // TODO `activeRunnersCount` should be implemented using a GraphQL API - // https://gitlab.com/gitlab-org/gitlab/-/issues/333806 - const { - runnerInstallHelpPage, - registrationToken, + // Redirect outdated URLs + const updatedUrlQuery = updateOutdatedUrl(); + if (updatedUrlQuery) { + visitUrl(updatedUrlQuery); - activeRunnersCount, - allRunnersCount, - instanceRunnersCount, - groupRunnersCount, - projectRunnersCount, - } = el.dataset; + // Prevent mounting the rest of the app, redirecting now. + return null; + } + + const { runnerInstallHelpPage, registrationToken } = el.dataset; const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), @@ -41,14 +41,6 @@ export const initAdminRunners = (selector = '#js-admin-runners') => { return h(AdminRunnersApp, { props: { registrationToken, - - // All runner counts are returned as formatted - // strings, we do not use `parseInt`. - activeRunnersCount, - allRunnersCount, - instanceRunnersCount, - groupRunnersCount, - projectRunnersCount, }, }); }, diff --git a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue index 33f7a67aba4..0934508c87f 100644 --- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue @@ -1,6 +1,6 @@ <script> import { GlButton, GlButtonGroup, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { __, s__, sprintf } from '~/locale'; import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql'; import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql'; @@ -69,6 +69,12 @@ export default { runnerDeleteModalId() { return `delete-runner-modal-${this.runnerId}`; }, + canUpdate() { + return this.runner.userPermissions?.updateRunner; + }, + canDelete() { + return this.runner.userPermissions?.deleteRunner; + }, }, methods: { async onToggleActive() { @@ -133,7 +139,7 @@ export default { onError(error) { const { message } = error; - createFlash({ message }); + createAlert({ message }); this.reportToSentry(error); }, @@ -156,14 +162,15 @@ export default { See https://gitlab.com/gitlab-org/gitlab/-/issues/334802 --> <gl-button - v-if="runner.adminUrl" + v-if="canUpdate && runner.editAdminUrl" v-gl-tooltip.hover.viewport="$options.I18N_EDIT" - :href="runner.adminUrl" + :href="runner.editAdminUrl" :aria-label="$options.I18N_EDIT" icon="pencil" data-testid="edit-runner" /> <gl-button + v-if="canUpdate" v-gl-tooltip.hover.viewport="toggleActiveTitle" :aria-label="toggleActiveTitle" :icon="toggleActiveIcon" @@ -172,6 +179,7 @@ export default { @click="onToggleActive" /> <gl-button + v-if="canDelete" v-gl-tooltip.hover.viewport="deleteTitle" v-gl-modal="runnerDeleteModalId" :aria-label="deleteTitle" @@ -182,6 +190,7 @@ export default { /> <runner-delete-modal + v-if="canDelete" :ref="runnerDeleteModalId" :modal-id="runnerDeleteModalId" :runner-name="runnerName" diff --git a/app/assets/javascripts/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/runner/components/cells/runner_status_cell.vue index 473cd7e9794..93f86ae2a2c 100644 --- a/app/assets/javascripts/runner/components/cells/runner_status_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_status_cell.vue @@ -28,7 +28,15 @@ export default { <template> <div> - <runner-status-badge :runner="runner" size="sm" /> - <runner-paused-badge v-if="paused" size="sm" /> + <runner-status-badge + :runner="runner" + size="sm" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + /> + <runner-paused-badge + v-if="paused" + size="sm" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + /> </div> </template> diff --git a/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue b/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue index 3bb15bff8d8..0e259807f98 100644 --- a/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue +++ b/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue @@ -1,6 +1,6 @@ <script> -import { GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { GlDropdownItem, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui'; +import { createAlert } from '~/flash'; import { TYPE_GROUP, TYPE_PROJECT } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { __, s__ } from '~/locale'; @@ -10,9 +10,17 @@ import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants'; export default { name: 'RunnerRegistrationTokenReset', + i18n: { + modalTitle: __('Reset registration token'), + modalCopy: __('Are you sure you want to reset the registration token?'), + }, components: { GlDropdownItem, GlLoadingIcon, + GlModal, + }, + directives: { + GlModal: GlModalDirective, }, inject: { groupId: { @@ -22,6 +30,7 @@ export default { default: null, }, }, + modalID: 'token-reset-modal', props: { type: { type: String, @@ -59,14 +68,10 @@ export default { }, }, methods: { + handleModalPrimary() { + this.resetToken(); + }, async resetToken() { - // TODO Replace confirmation with gl-modal - // See: https://gitlab.com/gitlab-org/gitlab/-/issues/333810 - // eslint-disable-next-line no-alert - if (!window.confirm(__('Are you sure you want to reset the registration token?'))) { - return; - } - this.loading = true; try { const { @@ -91,7 +96,7 @@ export default { }, onError(error) { const { message } = error; - createFlash({ message }); + createAlert({ message }); this.reportToSentry(error); }, @@ -106,8 +111,15 @@ export default { }; </script> <template> - <gl-dropdown-item @click.capture.native.stop="resetToken"> + <gl-dropdown-item v-gl-modal="$options.modalID"> {{ __('Reset registration token') }} + <gl-modal + :modal-id="$options.modalID" + :title="$options.i18n.modalTitle" + @primary="handleModalPrimary" + > + <p>{{ $options.i18n.modalCopy }}</p> + </gl-modal> <gl-loading-icon v-if="loading" inline /> </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/runner/components/runner_header.vue b/app/assets/javascripts/runner/components/runner_header.vue new file mode 100644 index 00000000000..09f58df7bd0 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_header.vue @@ -0,0 +1,52 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import { sprintf } from '~/locale'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { I18N_DETAILS_TITLE } from '../constants'; +import RunnerTypeBadge from './runner_type_badge.vue'; +import RunnerStatusBadge from './runner_status_badge.vue'; + +export default { + components: { + GlSprintf, + TimeAgo, + RunnerTypeBadge, + RunnerStatusBadge, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + computed: { + paused() { + return !this.runner.active; + }, + heading() { + const id = getIdFromGraphQLId(this.runner.id); + return sprintf(I18N_DETAILS_TITLE, { runner_id: id }); + }, + }, +}; +</script> +<template> + <div class="gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"> + <runner-status-badge :runner="runner" /> + <runner-type-badge v-if="runner" :type="runner.runnerType" /> + <template v-if="runner.createdAt"> + <gl-sprintf :message="__('%{runner} created %{timeago}')"> + <template #runner> + <strong>{{ heading }}</strong> + </template> + <template #timeago> + <time-ago :time="runner.createdAt" /> + </template> + </gl-sprintf> + </template> + <template v-else> + <strong>{{ heading }}</strong> + </template> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/runner_status_badge.vue b/app/assets/javascripts/runner/components/runner_status_badge.vue index 0823876a187..6d0445ecb7a 100644 --- a/app/assets/javascripts/runner/components/runner_status_badge.vue +++ b/app/assets/javascripts/runner/components/runner_status_badge.vue @@ -4,11 +4,10 @@ import { __, s__, sprintf } from '~/locale'; import { getTimeago } from '~/lib/utils/datetime_utility'; import { I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION, - I18N_NOT_CONNECTED_RUNNER_DESCRIPTION, + I18N_NEVER_CONTACTED_RUNNER_DESCRIPTION, I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION, I18N_STALE_RUNNER_DESCRIPTION, STATUS_ONLINE, - STATUS_NOT_CONNECTED, STATUS_NEVER_CONTACTED, STATUS_OFFLINE, STATUS_STALE, @@ -45,12 +44,11 @@ export default { timeAgo: this.contactedAtTimeAgo, }), }; - case STATUS_NOT_CONNECTED: case STATUS_NEVER_CONTACTED: return { variant: 'muted', - label: s__('Runners|not connected'), - tooltip: I18N_NOT_CONNECTED_RUNNER_DESCRIPTION, + label: s__('Runners|never contacted'), + tooltip: I18N_NEVER_CONTACTED_RUNNER_DESCRIPTION, }; case STATUS_OFFLINE: return { diff --git a/app/assets/javascripts/runner/components/runner_type_alert.vue b/app/assets/javascripts/runner/components/runner_type_alert.vue deleted file mode 100644 index 1400875a1d6..00000000000 --- a/app/assets/javascripts/runner/components/runner_type_alert.vue +++ /dev/null @@ -1,54 +0,0 @@ -<script> -import { GlAlert, GlLink } from '@gitlab/ui'; -import { helpPagePath } from '~/helpers/help_page_helper'; -import { s__ } from '~/locale'; -import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants'; - -const ALERT_DATA = { - [INSTANCE_TYPE]: { - message: s__( - 'Runners|This runner is available to all groups and projects in your GitLab instance.', - ), - anchor: 'shared-runners', - }, - [GROUP_TYPE]: { - message: s__('Runners|This runner is available to all projects and subgroups in a group.'), - anchor: 'group-runners', - }, - [PROJECT_TYPE]: { - message: s__('Runners|This runner is associated with one or more projects.'), - anchor: 'specific-runners', - }, -}; - -export default { - components: { - GlAlert, - GlLink, - }, - props: { - type: { - type: String, - required: false, - default: null, - validator(type) { - return Boolean(ALERT_DATA[type]); - }, - }, - }, - computed: { - alert() { - return ALERT_DATA[this.type]; - }, - helpHref() { - return helpPagePath('ci/runners/runners_scope', { anchor: this.alert.anchor }); - }, - }, -}; -</script> -<template> - <gl-alert v-if="alert" variant="info" :dismissible="false"> - {{ alert.message }} - <gl-link :href="helpHref">{{ __('Learn more.') }}</gl-link> - </gl-alert> -</template> diff --git a/app/assets/javascripts/runner/components/runner_update_form.vue b/app/assets/javascripts/runner/components/runner_update_form.vue index 9a6fc07f6dd..e3deb94236e 100644 --- a/app/assets/javascripts/runner/components/runner_update_form.vue +++ b/app/assets/javascripts/runner/components/runner_update_form.vue @@ -10,8 +10,8 @@ import { import { modelToUpdateMutationVariables, runnerToModel, -} from 'ee_else_ce/runner/runner_details/runner_update_form_utils'; -import createFlash, { FLASH_TYPES } from '~/flash'; +} from 'ee_else_ce/runner/runner_update_form_utils'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; import { __ } from '~/locale'; import { captureException } from '~/runner/sentry_utils'; import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants'; @@ -75,14 +75,14 @@ export default { if (errors?.length) { // Validation errors need not be thrown - createFlash({ message: errors[0] }); + createAlert({ message: errors[0] }); return; } this.onSuccess(); } catch (error) { const { message } = error; - createFlash({ message }); + createAlert({ message }); this.reportToSentry(error); } finally { @@ -90,7 +90,7 @@ export default { } }, onSuccess() { - createFlash({ message: __('Changes saved.'), type: FLASH_TYPES.SUCCESS }); + createAlert({ message: __('Changes saved.'), variant: VARIANT_SUCCESS }); this.model = runnerToModel(this.runner); }, reportToSentry(error) { diff --git a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js index 4b356fa47ed..79038eb8228 100644 --- a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js +++ b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js @@ -6,7 +6,7 @@ import { STATUS_PAUSED, STATUS_ONLINE, STATUS_OFFLINE, - STATUS_NOT_CONNECTED, + STATUS_NEVER_CONTACTED, STATUS_STALE, PARAM_KEY_STATUS, } from '../../constants'; @@ -16,7 +16,7 @@ const options = [ { value: STATUS_PAUSED, title: s__('Runners|Paused') }, { value: STATUS_ONLINE, title: s__('Runners|Online') }, { value: STATUS_OFFLINE, title: s__('Runners|Offline') }, - { value: STATUS_NOT_CONNECTED, title: s__('Runners|Not connected') }, + { value: STATUS_NEVER_CONTACTED, title: s__('Runners|Never contacted') }, { value: STATUS_STALE, title: s__('Runners|Stale') }, ]; diff --git a/app/assets/javascripts/runner/components/search_tokens/tag_token.vue b/app/assets/javascripts/runner/components/search_tokens/tag_token.vue index 7461308ab91..59230bb809e 100644 --- a/app/assets/javascripts/runner/components/search_tokens/tag_token.vue +++ b/app/assets/javascripts/runner/components/search_tokens/tag_token.vue @@ -1,6 +1,6 @@ <script> import { GlFilteredSearchSuggestion, GlToken } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; @@ -50,7 +50,7 @@ export default { try { this.tags = await this.getTagsOptions(searchTerm); } catch { - createFlash({ + createAlert({ message: s__('Runners|Something went wrong while fetching the tags suggestions'), }); } finally { diff --git a/app/assets/javascripts/runner/components/stat/runner_online_stat.vue b/app/assets/javascripts/runner/components/stat/runner_online_stat.vue deleted file mode 100644 index b92b9badef0..00000000000 --- a/app/assets/javascripts/runner/components/stat/runner_online_stat.vue +++ /dev/null @@ -1,17 +0,0 @@ -<script> -import { GlSingleStat } from '@gitlab/ui/dist/charts'; - -export default { - components: { - GlSingleStat, - }, -}; -</script> -<template> - <gl-single-stat - v-bind="$attrs" - variant="success" - :title="s__('Runners|Online Runners')" - :meta-text="s__('Runners|online')" - /> -</template> diff --git a/app/assets/javascripts/runner/components/stat/runner_stats.vue b/app/assets/javascripts/runner/components/stat/runner_stats.vue new file mode 100644 index 00000000000..d3693ee593e --- /dev/null +++ b/app/assets/javascripts/runner/components/stat/runner_stats.vue @@ -0,0 +1,49 @@ +<script> +import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '../../constants'; +import RunnerStatusStat from './runner_status_stat.vue'; + +export default { + components: { + RunnerStatusStat, + }, + props: { + onlineRunnersCount: { + type: Number, + required: false, + default: null, + }, + offlineRunnersCount: { + type: Number, + required: false, + default: null, + }, + staleRunnersCount: { + type: Number, + required: false, + default: null, + }, + }, + STATUS_ONLINE, + STATUS_OFFLINE, + STATUS_STALE, +}; +</script> +<template> + <div class="gl-display-flex gl-py-6"> + <runner-status-stat + class="gl-px-5" + :status="$options.STATUS_ONLINE" + :value="onlineRunnersCount" + /> + <runner-status-stat + class="gl-px-5" + :status="$options.STATUS_OFFLINE" + :value="offlineRunnersCount" + /> + <runner-status-stat + class="gl-px-5" + :status="$options.STATUS_STALE" + :value="staleRunnersCount" + /> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/stat/runner_status_stat.vue b/app/assets/javascripts/runner/components/stat/runner_status_stat.vue new file mode 100644 index 00000000000..b77bbe15541 --- /dev/null +++ b/app/assets/javascripts/runner/components/stat/runner_status_stat.vue @@ -0,0 +1,65 @@ +<script> +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { s__, formatNumber } from '~/locale'; +import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '../../constants'; + +export default { + components: { + GlSingleStat, + }, + props: { + value: { + type: Number, + required: false, + default: null, + }, + status: { + type: String, + required: true, + }, + }, + computed: { + formattedValue() { + if (typeof this.value === 'number') { + return formatNumber(this.value); + } + return '-'; + }, + stat() { + switch (this.status) { + case STATUS_ONLINE: + return { + variant: 'success', + title: s__('Runners|Online runners'), + metaText: s__('Runners|online'), + }; + case STATUS_OFFLINE: + return { + variant: 'muted', + title: s__('Runners|Offline runners'), + metaText: s__('Runners|offline'), + }; + case STATUS_STALE: + return { + variant: 'warning', + title: s__('Runners|Stale runners'), + metaText: s__('Runners|stale'), + }; + default: + return { + title: s__('Runners|Runners'), + }; + } + }, + }, +}; +</script> +<template> + <gl-single-stat + v-if="stat" + :value="formattedValue" + :variant="stat.variant" + :title="stat.title" + :meta-text="stat.metaText" + /> +</template> diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js index 355f3054917..ce8019ffaa0 100644 --- a/app/assets/javascripts/runner/constants.js +++ b/app/assets/javascripts/runner/constants.js @@ -18,8 +18,8 @@ export const I18N_PROJECT_RUNNER_DESCRIPTION = s__('Runners|Associated with one export const I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION = s__( 'Runners|Runner is online; last contact was %{timeAgo}', ); -export const I18N_NOT_CONNECTED_RUNNER_DESCRIPTION = s__( - 'Runners|This runner has never connected to this instance', +export const I18N_NEVER_CONTACTED_RUNNER_DESCRIPTION = s__( + 'Runners|This runner has never contacted this instance', ); export const I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION = s__( 'Runners|No recent contact from this runner; last contact was %{timeAgo}', @@ -60,7 +60,6 @@ export const STATUS_ACTIVE = 'ACTIVE'; export const STATUS_PAUSED = 'PAUSED'; export const STATUS_ONLINE = 'ONLINE'; -export const STATUS_NOT_CONNECTED = 'NOT_CONNECTED'; export const STATUS_NEVER_CONTACTED = 'NEVER_CONTACTED'; export const STATUS_OFFLINE = 'OFFLINE'; export const STATUS_STALE = 'STALE'; diff --git a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql index 6da9e276f74..f7bcd683718 100644 --- a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql @@ -13,7 +13,7 @@ query getGroupRunners( $sort: CiRunnerSort ) { group(fullPath: $groupFullPath) { - id + id # Apollo required runners( membership: DESCENDANTS before: $before diff --git a/app/assets/javascripts/runner/graphql/get_group_runners_count.query.graphql b/app/assets/javascripts/runner/graphql/get_group_runners_count.query.graphql new file mode 100644 index 00000000000..554eb09e372 --- /dev/null +++ b/app/assets/javascripts/runner/graphql/get_group_runners_count.query.graphql @@ -0,0 +1,20 @@ +query getGroupRunnersCount( + $groupFullPath: ID! + $status: CiRunnerStatus + $type: CiRunnerType + $tagList: [String!] + $search: String +) { + group(fullPath: $groupFullPath) { + id # Apollo required + runners( + membership: DESCENDANTS + status: $status + type: $type + tagList: $tagList + search: $search + ) { + count + } + } +} diff --git a/app/assets/javascripts/runner/graphql/get_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_runners.query.graphql index 51a91b9eb96..05df399fa6a 100644 --- a/app/assets/javascripts/runner/graphql/get_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/get_runners.query.graphql @@ -26,6 +26,7 @@ query getRunners( nodes { ...RunnerNode adminUrl + editAdminUrl } pageInfo { ...PageInfo diff --git a/app/assets/javascripts/runner/graphql/get_runners_count.query.graphql b/app/assets/javascripts/runner/graphql/get_runners_count.query.graphql new file mode 100644 index 00000000000..181a4495cae --- /dev/null +++ b/app/assets/javascripts/runner/graphql/get_runners_count.query.graphql @@ -0,0 +1,10 @@ +query getRunnersCount( + $status: CiRunnerStatus + $type: CiRunnerType + $tagList: [String!] + $search: String +) { + runners(status: $status, type: $type, tagList: $tagList, search: $search) { + count + } +} diff --git a/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql index 8c50cba7de3..8e968343b9b 100644 --- a/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql @@ -9,4 +9,6 @@ fragment RunnerDetailsShared on CiRunner { description maximumTimeout tagList + createdAt + status(legacyMode: null) } diff --git a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql index 169f6ffd2ea..4a771d779dc 100644 --- a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql @@ -12,4 +12,8 @@ fragment RunnerNode on CiRunner { tagList contactedAt status(legacyMode: null) + userPermissions { + updateRunner + deleteRunner + } } diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue index a58a53a6a0d..3a7b58e3dc9 100644 --- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue @@ -1,6 +1,6 @@ <script> import { GlLink } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; import { updateHistory } from '~/lib/utils/url_utility'; import { formatNumber, sprintf, s__ } from '~/locale'; @@ -9,7 +9,7 @@ import RegistrationDropdown from '../components/registration/registration_dropdo import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; import RunnerList from '../components/runner_list.vue'; import RunnerName from '../components/runner_name.vue'; -import RunnerOnlineStat from '../components/stat/runner_online_stat.vue'; +import RunnerStats from '../components/stat/runner_stats.vue'; import RunnerPagination from '../components/runner_pagination.vue'; import RunnerTypeTabs from '../components/runner_type_tabs.vue'; @@ -19,8 +19,12 @@ import { GROUP_FILTERED_SEARCH_NAMESPACE, GROUP_TYPE, GROUP_RUNNER_COUNT_LIMIT, + STATUS_ONLINE, + STATUS_OFFLINE, + STATUS_STALE, } from '../constants'; import getGroupRunnersQuery from '../graphql/get_group_runners.query.graphql'; +import getGroupRunnersCountQuery from '../graphql/get_group_runners_count.query.graphql'; import { fromUrlQueryToSearch, fromSearchToUrl, @@ -28,6 +32,17 @@ import { } from '../runner_search_utils'; import { captureException } from '../sentry_utils'; +const runnersCountSmartQuery = { + query: getGroupRunnersCountQuery, + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + update(data) { + return data?.group?.runners?.count; + }, + error(error) { + this.reportToSentry(error); + }, +}; + export default { name: 'GroupRunnersApp', components: { @@ -36,7 +51,7 @@ export default { RunnerFilteredSearchBar, RunnerList, RunnerName, - RunnerOnlineStat, + RunnerStats, RunnerPagination, RunnerTypeTabs, }, @@ -84,11 +99,38 @@ export default { }; }, error(error) { - createFlash({ message: I18N_FETCH_ERROR }); + createAlert({ message: I18N_FETCH_ERROR }); this.reportToSentry(error); }, }, + onlineRunnersTotal: { + ...runnersCountSmartQuery, + variables() { + return { + groupFullPath: this.groupFullPath, + status: STATUS_ONLINE, + }; + }, + }, + offlineRunnersTotal: { + ...runnersCountSmartQuery, + variables() { + return { + groupFullPath: this.groupFullPath, + status: STATUS_OFFLINE, + }; + }, + }, + staleRunnersTotal: { + ...runnersCountSmartQuery, + variables() { + return { + groupFullPath: this.groupFullPath, + status: STATUS_STALE, + }; + }, + }, }, computed: { variables() { @@ -147,7 +189,11 @@ export default { <template> <div> - <runner-online-stat class="gl-py-6 gl-px-5" :value="groupRunnersCount" /> + <runner-stats + :online-runners-count="onlineRunnersTotal" + :offline-runners-count="offlineRunnersTotal" + :stale-runners-count="staleRunnersTotal" + /> <div class="gl-display-flex gl-align-items-center"> <runner-type-tabs diff --git a/app/assets/javascripts/runner/runner_search_utils.js b/app/assets/javascripts/runner/runner_search_utils.js index b88023720e8..c80a73948b8 100644 --- a/app/assets/javascripts/runner/runner_search_utils.js +++ b/app/assets/javascripts/runner/runner_search_utils.js @@ -16,6 +16,7 @@ import { PARAM_KEY_BEFORE, DEFAULT_SORT, RUNNER_PAGE_SIZE, + STATUS_NEVER_CONTACTED, } from './constants'; /** @@ -79,6 +80,33 @@ const getPaginationFromParams = (params) => { }; }; +// Outdated URL parameters +const STATUS_NOT_CONNECTED = 'NOT_CONNECTED'; + +/** + * Returns an updated URL for old (or deprecated) admin runner URLs. + * + * Use for redirecting users to currently used URLs. + * + * @param {String?} URL + * @returns Updated URL if outdated, `null` otherwise + */ +export const updateOutdatedUrl = (url = window.location.href) => { + const urlObj = new URL(url); + const query = urlObj.search; + + const params = queryToObject(query, { gatherArrays: true }); + + const runnerType = params[PARAM_KEY_STATUS]?.[0] || null; + if (runnerType === STATUS_NOT_CONNECTED) { + const updatedParams = { + [PARAM_KEY_STATUS]: [STATUS_NEVER_CONTACTED], + }; + return setUrlParams(updatedParams, url, false, true, true); + } + return null; +}; + /** * Takes a URL query and transforms it into a "search" object * @param {String?} query diff --git a/app/assets/javascripts/runner/runner_details/runner_update_form_utils.js b/app/assets/javascripts/runner/runner_update_form_utils.js index 3b519fa7d71..3b519fa7d71 100644 --- a/app/assets/javascripts/runner/runner_details/runner_update_form_utils.js +++ b/app/assets/javascripts/runner/runner_update_form_utils.js diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue index 75d2b324623..d228f77f27d 100644 --- a/app/assets/javascripts/security_configuration/components/app.vue +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -27,6 +27,9 @@ export const i18n = { securityConfiguration: __('Security Configuration'), vulnerabilityManagement: s__('SecurityConfiguration|Vulnerability Management'), securityTraining: s__('SecurityConfiguration|Security training'), + securityTrainingDescription: s__( + 'SecurityConfiguration|Enable security training to help your developers learn how to fix vulnerabilities. Developers can view security training from selected educational providers, relevant to the detected vulnerability.', + ), }; export default { @@ -160,8 +163,12 @@ export default { </template> </user-callout-dismisser> - <gl-tabs content-class="gl-pt-0"> - <gl-tab data-testid="security-testing-tab" :title="$options.i18n.securityTesting"> + <gl-tabs content-class="gl-pt-0" sync-active-tab-with-query-params lazy> + <gl-tab + data-testid="security-testing-tab" + :title="$options.i18n.securityTesting" + query-param-value="security-testing" + > <auto-dev-ops-enabled-alert v-if="shouldShowAutoDevopsEnabledAlert" class="gl-mt-3" @@ -185,9 +192,12 @@ export default { {{ $options.i18n.description }} </p> <p v-if="canViewCiHistory"> - <gl-link data-testid="security-view-history-link" :href="gitlabCiHistoryPath">{{ - $options.i18n.configurationHistory - }}</gl-link> + <gl-link + data-testid="security-view-history-link" + data-qa-selector="security_configuration_history_link" + :href="gitlabCiHistoryPath" + >{{ $options.i18n.configurationHistory }}</gl-link + > </p> </template> @@ -203,7 +213,11 @@ export default { </template> </section-layout> </gl-tab> - <gl-tab data-testid="compliance-testing-tab" :title="$options.i18n.compliance"> + <gl-tab + data-testid="compliance-testing-tab" + :title="$options.i18n.compliance" + query-param-value="compliance-testing" + > <section-layout :heading="$options.i18n.compliance"> <template #description> <p> @@ -241,8 +255,14 @@ export default { v-if="glFeatures.secureVulnerabilityTraining" data-testid="vulnerability-management-tab" :title="$options.i18n.vulnerabilityManagement" + query-param-value="vulnerability-management" > <section-layout :heading="$options.i18n.securityTraining"> + <template #description> + <p> + {{ $options.i18n.securityTrainingDescription }} + </p> + </template> <template #features> <training-provider-list /> </template> diff --git a/app/assets/javascripts/security_configuration/components/auto_dev_ops_alert.vue b/app/assets/javascripts/security_configuration/components/auto_dev_ops_alert.vue index ce6a1b4888b..315f676e659 100644 --- a/app/assets/javascripts/security_configuration/components/auto_dev_ops_alert.vue +++ b/app/assets/javascripts/security_configuration/components/auto_dev_ops_alert.vue @@ -28,6 +28,7 @@ export default { variant="info" :primary-button-link="autoDevopsPath" :primary-button-text="$options.i18n.primaryButtonText" + data-qa-selector="autodevops_container" @dismiss="dismissMethod" > <gl-sprintf :message="$options.i18n.body"> diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index dd8ba72ad1f..034dba29196 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -254,7 +254,7 @@ export const securityFeatures = [ helpPath: COVERAGE_FUZZING_HELP_PATH, configurationHelpPath: COVERAGE_FUZZING_CONFIG_HELP_PATH, type: REPORT_TYPE_COVERAGE_FUZZING, - secondary: gon?.features?.corpusManagement + secondary: gon?.features?.corpusManagementUi ? { type: REPORT_TYPE_CORPUS_MANAGEMENT, name: CORPUS_MANAGEMENT_NAME, diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue index 509377a63e8..ca4596e16b3 100644 --- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue +++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue @@ -1,21 +1,39 @@ <script> -import { GlCard, GlToggle, GlLink, GlSkeletonLoader } from '@gitlab/ui'; +import { GlAlert, GlCard, GlToggle, GlLink, GlSkeletonLoader } from '@gitlab/ui'; +import { __ } from '~/locale'; import securityTrainingProvidersQuery from '../graphql/security_training_providers.query.graphql'; +import configureSecurityTrainingProvidersMutation from '../graphql/configure_security_training_providers.mutation.graphql'; + +const i18n = { + providerQueryErrorMessage: __( + 'Could not fetch training providers. Please refresh the page, or try again later.', + ), + configMutationErrorMessage: __( + 'Could not save configuration. Please refresh the page, or try again later.', + ), +}; export default { components: { + GlAlert, GlCard, GlToggle, GlLink, GlSkeletonLoader, }, + inject: ['projectPath'], apollo: { securityTrainingProviders: { query: securityTrainingProvidersQuery, + error() { + this.errorMessage = this.$options.i18n.providerQueryErrorMessage; + }, }, }, data() { return { + errorMessage: '', + toggleLoading: false, securityTrainingProviders: [], }; }, @@ -24,38 +42,92 @@ export default { return this.$apollo.queries.securityTrainingProviders.loading; }, }, + methods: { + toggleProvider(selectedProviderId) { + const toggledProviders = this.securityTrainingProviders.map((provider) => ({ + ...provider, + ...(provider.id === selectedProviderId && { isEnabled: !provider.isEnabled }), + })); + + const enabledProviderIds = toggledProviders + .filter(({ isEnabled }) => isEnabled) + .map(({ id }) => id); + + this.storeEnabledProviders(toggledProviders, enabledProviderIds); + }, + async storeEnabledProviders(toggledProviders, enabledProviderIds) { + this.toggleLoading = true; + + try { + const { + data: { + configureSecurityTrainingProviders: { errors = [] }, + }, + } = await this.$apollo.mutate({ + mutation: configureSecurityTrainingProvidersMutation, + variables: { + input: { + enabledProviders: enabledProviderIds, + fullPath: this.projectPath, + }, + }, + }); + + if (errors.length > 0) { + // throwing an error here means we can handle scenarios within the `catch` block below + throw new Error(); + } + } catch { + this.errorMessage = this.$options.i18n.configMutationErrorMessage; + } finally { + this.toggleLoading = false; + } + }, + }, + i18n, }; </script> <template> - <div - v-if="isLoading" - class="gl-bg-white gl-py-6 gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100" - > - <gl-skeleton-loader :width="350" :height="44"> - <rect width="200" height="8" x="10" y="0" rx="4" /> - <rect width="300" height="8" x="10" y="15" rx="4" /> - <rect width="100" height="8" x="10" y="35" rx="4" /> - </gl-skeleton-loader> - </div> - <ul v-else class="gl-list-style-none gl-m-0 gl-p-0"> - <li - v-for="{ id, isEnabled, name, description, url } in securityTrainingProviders" - :key="id" - class="gl-mb-6" + <div> + <gl-alert v-if="errorMessage" variant="danger" :dismissible="false" class="gl-mb-6"> + {{ errorMessage }} + </gl-alert> + <div + v-if="isLoading" + class="gl-bg-white gl-py-6 gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100" > - <gl-card> - <div class="gl-display-flex"> - <gl-toggle :value="isEnabled" :label="__('Training mode')" label-position="hidden" /> - <div class="gl-ml-5"> - <h3 class="gl-font-lg gl-m-0 gl-mb-2">{{ name }}</h3> - <p> - {{ description }} - <gl-link :href="url" target="_blank">{{ __('Learn more.') }}</gl-link> - </p> + <gl-skeleton-loader :width="350" :height="44"> + <rect width="200" height="8" x="10" y="0" rx="4" /> + <rect width="300" height="8" x="10" y="15" rx="4" /> + <rect width="100" height="8" x="10" y="35" rx="4" /> + </gl-skeleton-loader> + </div> + <ul v-else class="gl-list-style-none gl-m-0 gl-p-0"> + <li + v-for="{ id, isEnabled, name, description, url } in securityTrainingProviders" + :key="id" + class="gl-mb-6" + > + <gl-card> + <div class="gl-display-flex"> + <gl-toggle + :value="isEnabled" + :label="__('Training mode')" + label-position="hidden" + :is-loading="toggleLoading" + @change="toggleProvider(id)" + /> + <div class="gl-ml-5"> + <h3 class="gl-font-lg gl-m-0 gl-mb-2">{{ name }}</h3> + <p> + {{ description }} + <gl-link :href="url" target="_blank">{{ __('Learn more.') }}</gl-link> + </p> + </div> </div> - </div> - </gl-card> - </li> - </ul> + </gl-card> + </li> + </ul> + </div> </template> diff --git a/app/assets/javascripts/security_configuration/graphql/configure_security_training_providers.mutation.graphql b/app/assets/javascripts/security_configuration/graphql/configure_security_training_providers.mutation.graphql new file mode 100644 index 00000000000..660e0fadafb --- /dev/null +++ b/app/assets/javascripts/security_configuration/graphql/configure_security_training_providers.mutation.graphql @@ -0,0 +1,9 @@ +mutation configureSecurityTrainingProviders($input: configureSecurityTrainingProvidersInput!) { + configureSecurityTrainingProviders(input: $input) @client { + errors + securityTrainingProviders { + id + isEnabled + } + } +} diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js index c86ff1a58f2..24c0585e077 100644 --- a/app/assets/javascripts/security_configuration/index.js +++ b/app/assets/javascripts/security_configuration/index.js @@ -2,38 +2,10 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { parseBooleanDataAttributes } from '~/lib/utils/dom_utils'; -import { __ } from '~/locale'; import SecurityConfigurationApp from './components/app.vue'; import { securityFeatures, complianceFeatures } from './components/constants'; import { augmentFeatures } from './utils'; - -// Note: this is behind a feature flag and only a placeholder -// until the actual GraphQL fields have been added -// https://gitlab.com/gitlab-org/gi tlab/-/issues/346480 -export const tempResolvers = { - Query: { - securityTrainingProviders() { - return [ - { - __typename: 'SecurityTrainingProvider', - id: 101, - name: __('Kontra'), - description: __('Interactive developer security education.'), - url: 'https://application.security/', - isEnabled: false, - }, - { - __typename: 'SecurityTrainingProvider', - id: 102, - name: __('SecureCodeWarrior'), - description: __('Security training with guide and learning pathways.'), - url: 'https://www.securecodewarrior.com/', - isEnabled: true, - }, - ]; - }, - }, -}; +import tempResolvers from './resolver'; export const initSecurityConfiguration = (el) => { if (!el) { diff --git a/app/assets/javascripts/security_configuration/resolver.js b/app/assets/javascripts/security_configuration/resolver.js new file mode 100644 index 00000000000..93175d4a3d1 --- /dev/null +++ b/app/assets/javascripts/security_configuration/resolver.js @@ -0,0 +1,56 @@ +import produce from 'immer'; +import { __ } from '~/locale'; +import securityTrainingProvidersQuery from './graphql/security_training_providers.query.graphql'; + +// Note: this is behind a feature flag and only a placeholder +// until the actual GraphQL fields have been added +// https://gitlab.com/gitlab-org/gi tlab/-/issues/346480 +export default { + Query: { + securityTrainingProviders() { + return [ + { + __typename: 'SecurityTrainingProvider', + id: 101, + name: __('Kontra'), + description: __('Interactive developer security education.'), + url: 'https://application.security/', + isEnabled: false, + }, + { + __typename: 'SecurityTrainingProvider', + id: 102, + name: __('SecureCodeWarrior'), + description: __('Security training with guide and learning pathways.'), + url: 'https://www.securecodewarrior.com/', + isEnabled: true, + }, + ]; + }, + }, + + Mutation: { + configureSecurityTrainingProviders: ( + _, + { input: { enabledProviders, primaryProvider } }, + { cache }, + ) => { + const sourceData = cache.readQuery({ + query: securityTrainingProvidersQuery, + }); + + const data = produce(sourceData.securityTrainingProviders, (draftData) => { + /* eslint-disable no-param-reassign */ + draftData.forEach((provider) => { + provider.isPrimary = provider.id === primaryProvider; + provider.isEnabled = + provider.id === primaryProvider || enabledProviders.includes(provider.id); + }); + }); + return { + __typename: 'configureSecurityTrainingProvidersPayload', + securityTrainingProviders: data, + }; + }, + }, +}; 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 e41f3aa5c9d..a746642c191 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 @@ -267,6 +267,8 @@ export default { v-if="glFeatures.improvedEmojiPicker" dropdown-class="gl-h-full" toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!" + boundary="viewport" + :right="false" @click="setEmoji" > <template #button-content> diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue index 6d4da104952..950647f1cb2 100644 --- a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue +++ b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon, GlPopover, GlTooltipDirective } from '@gitlab/ui'; +import { GlIcon, GlLink, GlPopover, GlTooltipDirective } from '@gitlab/ui'; import { __, n__, sprintf } from '~/locale'; import createFlash from '~/flash'; import { convertToGraphQLId } from '~/graphql_shared/utils'; @@ -10,6 +10,7 @@ import issueCrmContactsSubscription from './queries/issue_crm_contacts.subscript export default { components: { GlIcon, + GlLink, GlPopover, }, directives: { @@ -85,9 +86,6 @@ export default { ); }, }, - i18n: { - help: __('Work in progress- click here to find out more'), - }, }; </script> @@ -97,11 +95,10 @@ export default { <gl-icon name="users" /> <span> {{ contactCount }} </span> </div> - <div - v-gl-tooltip.left.viewport="$options.i18n.help" - class="hide-collapsed help-button float-right" - > - <a href="https://gitlab.com/gitlab-org/gitlab/-/issues/2256"><gl-icon name="question-o" /></a> + <div class="hide-collapsed help-button gl-float-right"> + <gl-link href="https://docs.gitlab.com/ee/user/crm/" target="_blank" + ><gl-icon name="question-o" + /></gl-link> </div> <div class="title hide-collapsed gl-mb-2 gl-line-height-20"> {{ contactsLabel }} diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index cbe40d0bfbe..6363422259e 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -26,6 +26,7 @@ import trackShowInviteMemberLink from '~/sidebar/track_invite_members'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; +import eventHub from '~/sidebar/event_hub'; import Translate from '../vue_shared/translate'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue'; @@ -600,6 +601,12 @@ export function mountSidebar(mediator, store) { mountTimeTrackingComponent(); mountSeverityComponent(); + + if (window.gon?.features?.mrAttentionRequests) { + eventHub.$on('removeCurrentUserAttentionRequested', () => + mediator.removeCurrentUserAttentionRequested(), + ); + } } export { getSidebarOptions }; diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index a49ddac8c89..25468d4a697 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -30,7 +30,7 @@ export default class SidebarMediator { this.store.addAssignee(this.store.currentUser); } - saveAssignees(field) { + async saveAssignees(field) { const selected = this.store.assignees.map((u) => u.id); // If there are no ids, that means we have to unassign (which is id = 0) @@ -38,10 +38,22 @@ export default class SidebarMediator { const assignees = selected.length === 0 ? [0] : selected; const data = { assignee_ids: assignees }; - return this.service.update(field, data); + try { + const res = await this.service.update(field, data); + + this.store.overwrite('assignees', res.data.assignees); + + if (res.data.reviewers) { + this.store.overwrite('reviewers', res.data.reviewers); + } + + return Promise.resolve(res); + } catch (e) { + return Promise.reject(e); + } } - saveReviewers(field) { + async saveReviewers(field) { const selected = this.store.reviewers.map((u) => u.id); // If there are no ids, that means we have to unassign (which is id = 0) @@ -49,7 +61,16 @@ export default class SidebarMediator { const reviewers = selected.length === 0 ? [0] : selected; const data = { reviewer_ids: reviewers }; - return this.service.update(field, data); + try { + const res = await this.service.update(field, data); + + this.store.overwrite('reviewers', res.data.reviewers); + this.store.overwrite('assignees', res.data.assignees); + + return Promise.resolve(res); + } catch (e) { + return Promise.reject(); + } } requestReview({ userId, callback }) { @@ -63,6 +84,19 @@ export default class SidebarMediator { .catch(() => callback(userId, false)); } + removeCurrentUserAttentionRequested() { + const currentUserId = gon.current_user_id; + + const currentUserReviewer = this.store.findReviewer({ id: currentUserId }); + const currentUserAssignee = this.store.findAssignee({ id: currentUserId }); + + if (currentUserReviewer?.attention_requested || currentUserAssignee?.attention_requested) { + // Update current users attention_requested state + this.store.updateReviewer(currentUserId, 'attention_requested'); + this.store.updateAssignee(currentUserId, 'attention_requested'); + } + } + async toggleAttentionRequested(type, { user, callback }) { try { const isReviewer = type === 'reviewer'; @@ -82,15 +116,7 @@ export default class SidebarMediator { const currentUserId = gon.current_user_id; if (currentUserId !== user.id) { - const currentUserReviewerOrAssignee = isReviewer - ? this.store.findReviewer({ id: currentUserId }) - : this.store.findAssignee({ id: currentUserId }); - - if (currentUserReviewerOrAssignee?.attention_requested) { - // Update current users attention_requested state - this.store.updateReviewer(currentUserId, 'attention_requested'); - this.store.updateAssignee(currentUserId, 'attention_requested'); - } + this.removeCurrentUserAttentionRequested(); } toast(sprintf(__('Requested attention from @%{username}'), { username: user.username })); diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index 5376791469e..2caa6f4f0a0 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -98,6 +98,10 @@ export default class SidebarStore { } } + overwrite(key, newData) { + this[key] = newData; + } + findAssignee(findAssignee) { return this.assignees.find(({ id }) => id === findAssignee.id); } diff --git a/app/assets/javascripts/tracking/index.js b/app/assets/javascripts/tracking/index.js index 7e99ecb4f4e..d60eb37a9a2 100644 --- a/app/assets/javascripts/tracking/index.js +++ b/app/assets/javascripts/tracking/index.js @@ -46,7 +46,10 @@ export function initDefaultTrackers() { // must be after enableActivityTracking const standardContext = getStandardContext(); const experimentContexts = getAllExperimentContexts(); - window.snowplow('trackPageView', null, [standardContext, ...experimentContexts]); + // To not expose personal identifying information, the page title is hardcoded as `GitLab` + // See: https://gitlab.com/gitlab-org/gitlab/-/issues/345243 + window.snowplow('trackPageView', 'GitLab', [standardContext, ...experimentContexts]); + window.snowplow('setDocumentTitle', 'GitLab'); if (window.snowplowOptions.formTracking) { Tracking.enableFormTracking(opts.formTrackingConfig); diff --git a/app/assets/javascripts/tree.js b/app/assets/javascripts/tree.js deleted file mode 100644 index 58bff370fa5..00000000000 --- a/app/assets/javascripts/tree.js +++ /dev/null @@ -1,64 +0,0 @@ -/* eslint-disable func-names, consistent-return, one-var, class-methods-use-this */ - -import $ from 'jquery'; -import { visitUrl } from './lib/utils/url_utility'; - -export default class TreeView { - constructor() { - this.initKeyNav(); - // Code browser tree slider - // Make the entire tree-item row clickable, but not if clicking another link (like a commit message) - $('.tree-content-holder .tree-item').on('click', function (e) { - const $clickedEl = $(e.target); - const path = $('.tree-item-file-name a', this).attr('href'); - if (!$clickedEl.is('a') && !$clickedEl.is('.str-truncated')) { - if (e.metaKey || e.which === 2) { - e.preventDefault(); - return window.open(path, '_blank'); - } - return visitUrl(path); - } - }); - // Show the "Loading commit data" for only the first element - $('span.log_loading').first().removeClass('hide'); - } - - initKeyNav() { - const li = $('tr.tree-item'); - let liSelected = null; - return $('body').keydown((e) => { - let next, path; - if ($('input:focus').length > 0 && (e.which === 38 || e.which === 40)) { - return false; - } - if (e.which === 40) { - if (liSelected) { - next = liSelected.next(); - if (next.length > 0) { - liSelected.removeClass('selected'); - liSelected = next.addClass('selected'); - } - } else { - liSelected = li.eq(0).addClass('selected'); - } - return $(liSelected).focus(); - } else if (e.which === 38) { - if (liSelected) { - next = liSelected.prev(); - if (next.length > 0) { - liSelected.removeClass('selected'); - liSelected = next.addClass('selected'); - } - } else { - liSelected = li.last().addClass('selected'); - } - return $(liSelected).focus(); - } else if (e.which === 13) { - path = $('.tree-item.selected .tree-item-file-name a').attr('href'); - if (path) { - return visitUrl(path); - } - } - }); - } -} diff --git a/app/assets/javascripts/version_check_image.js b/app/assets/javascripts/version_check_image.js deleted file mode 100644 index 4e00e0f11f7..00000000000 --- a/app/assets/javascripts/version_check_image.js +++ /dev/null @@ -1,6 +0,0 @@ -export default class VersionCheckImage { - static bindErrorEvent(imageElement) { - // eslint-disable-next-line @gitlab/no-global-event-off - imageElement.off('error').on('error', () => imageElement.hide()); - } -} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue index 386ba2e2d77..24cefd63ce3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue @@ -3,6 +3,7 @@ import { GlButton } from '@gitlab/ui'; import createFlash from '~/flash'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { s__ } from '~/locale'; +import sidebarEventHub from '~/sidebar/event_hub'; import eventHub from '../../event_hub'; import approvalsMixin from '../../mixins/approvals'; import MrWidgetContainer from '../mr_widget_container.vue'; @@ -172,6 +173,7 @@ export default { this.mr.setApprovals(data); eventHub.$emit('MRWidgetUpdateRequested'); eventHub.$emit('ApprovalUpdated'); + sidebarEventHub.$emit('removeCurrentUserAttentionRequested'); this.$emit('updated'); }) .catch(errFn) diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue index 33a83aef057..d878a1fa2e0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue @@ -70,6 +70,7 @@ export default { variant="confirm" size="small" class="gl-display-none gl-md-display-block gl-float-left" + data-testid="extension-actions-button" @click="onClickAction(btn)" > {{ btn.text }} 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 549cf64fb08..7322958e6df 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 @@ -13,6 +13,7 @@ import * as Sentry from '@sentry/browser'; import api from '~/api'; import { sprintf, s__, __ } from '~/locale'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; +import Poll from '~/lib/utils/poll'; import { EXTENSION_ICON_CLASS, EXTENSION_ICONS } from '../../constants'; import StatusIcon from './status_icon.vue'; import Actions from './actions.vue'; @@ -132,19 +133,50 @@ export default { this.triggerRedisTracking(); }, + initExtensionPolling() { + const poll = new Poll({ + resource: { + fetchData: () => this.fetchCollapsedData(this.$props), + }, + method: 'fetchData', + successCallback: (data) => { + if (Object.keys(data).length > 0) { + poll.stop(); + this.setCollapsedData(data); + } + }, + errorCallback: (e) => { + poll.stop(); + + this.setCollapsedError(e); + }, + }); + + poll.makeRequest(); + }, loadCollapsedData() { this.loadingState = LOADING_STATES.collapsedLoading; - this.fetchCollapsedData(this.$props) - .then((data) => { - this.collapsedData = data; - this.loadingState = null; - }) - .catch((e) => { - this.loadingState = LOADING_STATES.collapsedError; + if (this.$options.enablePolling) { + this.initExtensionPolling(); + } else { + this.fetchCollapsedData(this.$props) + .then((data) => { + this.setCollapsedData(data); + }) + .catch((e) => { + this.setCollapsedError(e); + }); + } + }, + setCollapsedData(data) { + this.collapsedData = data; + this.loadingState = null; + }, + setCollapsedError(e) { + this.loadingState = LOADING_STATES.collapsedError; - Sentry.captureException(e); - }); + Sentry.captureException(e); }, loadAllData() { if (this.hasFullData) return; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js index ec6e6ed2620..8438f3492b2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js @@ -13,6 +13,7 @@ export const registerExtension = (extension) => { props: extension.props, i18n: extension.i18n, expandEvent: extension.expandEvent, + enablePolling: extension.enablePolling, computed: { ...Object.keys(extension.computed).reduce( (acc, computedKey) => ({ diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index 235a200b747..8cdaa3316ee 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -119,6 +119,8 @@ export default { :show-gitpod-button="mr.showGitpodButton" :gitpod-url="mr.gitpodUrl" :gitpod-enabled="mr.gitpodEnabled" + :user-preferences-gitpod-path="mr.userPreferencesGitpodPath" + :user-profile-enable-gitpod-path="mr.userProfileEnableGitpodPath" :gitpod-text="$options.i18n.gitpodText" class="gl-display-none gl-md-display-inline-block gl-mr-3" data-placement="bottom" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index 677c50ed930..2e3a02b1712 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlLoadingIcon } from '@gitlab/ui'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ciIcon from '../../vue_shared/components/ci_icon.vue'; export default { @@ -8,6 +9,7 @@ export default { GlButton, GlLoadingIcon, }, + mixins: [glFeatureFlagMixin()], props: { status: { type: String, @@ -42,7 +44,7 @@ export default { </div> <gl-button - v-if="showDisabledButton" + v-if="!glFeatures.restructuredMrWidget && showDisabledButton" category="primary" variant="success" data-testid="disabled-merge-button" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue index ce572f8b0bf..701ef89304c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue @@ -1,20 +1,16 @@ <script> -import { GlButton } from '@gitlab/ui'; import { s__ } from '~/locale'; -import notesEventHub from '~/notes/event_hub'; import StatusIcon from '../mr_widget_status_icon.vue'; export default { i18n: { - pipelineFailed: s__( - 'mrWidget|The pipeline for this merge request did not complete. Push a new commit to fix the failure.', - ), approvalNeeded: s__('mrWidget|Merge blocked: this merge request must be approved.'), - unresolvedDiscussions: s__('mrWidget|Merge blocked: all threads must be resolved.'), + blockingMergeRequests: s__( + 'mrWidget|Merge blocked: you can only merge after the above items are resolved.', + ), }, components: { StatusIcon, - GlButton, }, props: { mr: { @@ -24,22 +20,15 @@ export default { }, computed: { failedText() { - if (this.mr.isPipelineFailed) { - return this.$options.i18n.pipelineFailed; - } else if (this.mr.approvals && !this.mr.isApproved) { + if (this.mr.approvals && !this.mr.isApproved) { return this.$options.i18n.approvalNeeded; - } else if (this.mr.hasMergeableDiscussionsState) { - return this.$options.i18n.unresolvedDiscussions; + } else if (this.mr.blockingMergeRequests?.total_count > 0) { + return this.$options.i18n.blockingMergeRequests; } return null; }, }, - methods: { - jumpToFirstUnresolvedDiscussion() { - notesEventHub.$emit('jumpToFirstUnresolvedDiscussion'); - }, - }, }; </script> @@ -48,28 +37,6 @@ export default { <status-icon status="warning" /> <p class="media-body gl-m-0! gl-font-weight-bold gl-text-black-normal!"> {{ failedText }} - <template v-if="failedText == $options.i18n.unresolvedDiscussions"> - <gl-button - class="gl-ml-3" - size="small" - variant="confirm" - data-testid="jumpToUnresolved" - @click="jumpToFirstUnresolvedDiscussion" - > - {{ s__('mrWidget|Jump to first unresolved thread') }} - </gl-button> - <gl-button - v-if="mr.createIssueToResolveDiscussionsPath" - :href="mr.createIssueToResolveDiscussionsPath" - class="gl-ml-3" - size="small" - variant="confirm" - category="secondary" - data-testid="resolveIssue" - > - {{ s__('mrWidget|Create issue to resolve all threads') }} - </gl-button> - </template> </p> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue index 13b1e49f44e..071920856a8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue @@ -1,25 +1,22 @@ <script> -import { GlButton } from '@gitlab/ui'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import statusIcon from '../mr_widget_status_icon.vue'; export default { name: 'MRWidgetArchived', components: { - GlButton, statusIcon, }, + mixins: [glFeatureFlagMixin()], }; </script> <template> <div class="mr-widget-body media"> <div class="space-children"> - <status-icon status="warning" /> - <gl-button category="secondary" variant="success" :disabled="true"> - {{ s__('mrWidget|Merge') }} - </gl-button> + <status-icon status="warning" show-disabled-button /> </div> <div class="media-body"> - <span class="bold"> + <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold"> {{ s__('mrWidget|Merge unavailable: merge requests are read-only on archived projects.') }} </span> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue index 10b93d7849f..fd42fa0421f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue @@ -12,9 +12,11 @@ export default { </script> <template> <div class="mr-widget-body media"> - <status-icon :show-disabled-button="!glFeatures.restructuredMrWidget" status="loading" /> + <status-icon :show-disabled-button="true" status="loading" /> <div class="media-body space-children"> - <span class="bold"> {{ s__('mrWidget|Checking if merge request can be merged…') }} </span> + <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold"> + {{ s__('mrWidget|Checking if merge request can be merged…') }} + </span> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue index 7a002d41ac0..a2c9cfe53cc 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 @@ -109,14 +109,18 @@ export default { </gl-skeleton-loader> </div> <div v-else class="media-body space-children gl-display-flex gl-align-items-center"> - <span v-if="shouldBeRebased" class="bold"> + <span + v-if="shouldBeRebased" + :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" + class="bold" + > {{ s__(`mrWidget|Merge blocked: fast-forward merge is not possible. To merge this request, first rebase locally.`) }} </span> <template v-else> - <span class="bold"> + <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold"> {{ s__('mrWidget|Merge blocked: merge conflicts must be resolved.') }} <span v-if="!canMerge"> {{ @@ -129,6 +133,7 @@ export default { <gl-button v-if="showResolveButton" :href="mr.conflictResolutionPath" + :size="glFeatures.restructuredMrWidget ? 'small' : 'medium'" data-testid="resolve-conflicts-button" > {{ s__('mrWidget|Resolve conflicts') }} @@ -136,6 +141,7 @@ export default { <gl-button v-if="canMerge" v-gl-modal-directive="'modal-merge-info'" + :size="glFeatures.restructuredMrWidget ? 'small' : 'medium'" data-testid="merge-locally-button" > {{ s__('mrWidget|Merge locally') }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue index f91350d4a82..5b03eda2eac 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue @@ -74,10 +74,21 @@ export default { <status-icon :show-disabled-button="true" status="warning" /> <div class="media-body space-children"> - <span class="bold js-branch-text"> + <span + :class="{ + 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget, + }" + class="bold js-branch-text" + > <span class="capitalize" data-testid="missingBranchName"> {{ missingBranchName }} </span> {{ s__('mrWidget|branch does not exist.') }} {{ missingBranchNameMessage }} - <gl-icon v-gl-tooltip :title="message" :aria-label="message" name="question-o" /> + <gl-icon + v-gl-tooltip + :title="message" + :aria-label="message" + name="question-o" + class="gl-text-blue-600 gl-cursor-pointer" + /> </span> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue index 68ffca9cd68..34c5a2ff2c8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue @@ -1,4 +1,5 @@ <script> +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import StatusIcon from '../mr_widget_status_icon.vue'; export default { @@ -6,13 +7,14 @@ export default { components: { StatusIcon, }, + mixins: [glFeatureFlagMixin()], }; </script> <template> <div class="mr-widget-body media"> <status-icon :show-disabled-button="true" status="warning" /> <div class="media-body space-children"> - <span class="bold"> + <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold"> {{ s__( `mrWidget|Merge blocked: pipeline must succeed. It's waiting for a manual action to continue.`, 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 01e8303f513..bb0fb410d3e 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 @@ -3,11 +3,13 @@ import { GlButton, GlSkeletonLoader } from '@gitlab/ui'; import createFlash from '~/flash'; import { __ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import ActionsButton from '~/vue_shared/components/actions_button.vue'; import simplePoll from '../../../lib/utils/simple_poll'; import eventHub from '../../event_hub'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import rebaseQuery from '../../queries/states/rebase.query.graphql'; import statusIcon from '../mr_widget_status_icon.vue'; +import { REBASE_BUTTON_KEY, REBASE_WITHOUT_CI_BUTTON_KEY } from '../../constants'; export default { name: 'MRWidgetRebase', @@ -25,8 +27,9 @@ export default { }, components: { statusIcon, - GlButton, GlSkeletonLoader, + ActionsButton, + GlButton, }, mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin], props: { @@ -44,12 +47,16 @@ export default { state: {}, isMakingRequest: false, rebasingError: null, + selectedRebaseAction: REBASE_BUTTON_KEY, }; }, computed: { isLoading() { return this.glFeatures.mergeRequestWidgetGraphql && this.$apollo.queries.state.loading; }, + showRebaseWithoutCi() { + return this.glFeatures?.rebaseWithoutCiUi; + }, rebaseInProgress() { if (this.glFeatures.mergeRequestWidgetGraphql) { return this.state.rebaseInProgress; @@ -86,14 +93,36 @@ export default { fastForwardMergeText() { return __('Merge blocked: the source branch must be rebased onto the target branch.'); }, + actions() { + return [this.rebaseAction, this.rebaseWithoutCiAction].filter((action) => action); + }, + rebaseAction() { + return { + key: REBASE_BUTTON_KEY, + text: __('Rebase'), + secondaryText: __('Rebases and triggers a pipeline'), + attrs: { + 'data-qa-selector': 'mr_rebase_button', + }, + handle: () => this.rebase(), + }; + }, + rebaseWithoutCiAction() { + return { + key: REBASE_WITHOUT_CI_BUTTON_KEY, + text: __('Rebase without CI'), + secondaryText: __('Performs a rebase but skips triggering a new pipeline'), + handle: () => this.rebase({ skipCi: true }), + }; + }, }, methods: { - rebase() { + rebase({ skipCi = false } = {}) { this.isMakingRequest = true; this.rebasingError = null; this.service - .rebase() + .rebase({ skipCi }) .then(() => { simplePoll(this.checkRebaseStatus); }) @@ -109,6 +138,9 @@ export default { } }); }, + selectRebaseAction(key) { + this.selectedRebaseAction = key; + }, checkRebaseStatus(continuePolling, stopPolling) { this.service .poll() @@ -152,12 +184,14 @@ export default { <div class="rebase-state-find-class-convention media media-body space-children"> <span v-if="rebaseInProgress || isMakingRequest" + :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="gl-font-weight-bold" data-testid="rebase-message" >{{ __('Rebase in progress') }}</span > <span v-if="!rebaseInProgress && !canPushToSourceBranch" + :class="{ 'gl-text-body!': glFeatures.restructuredMrWidget }" class="gl-font-weight-bold gl-ml-0!" data-testid="rebase-message" >{{ fastForwardMergeText }}</span @@ -167,15 +201,26 @@ export default { class="accept-merge-holder clearfix js-toggle-container accept-action media space-children" > <gl-button + v-if="!glFeatures.restructuredMrWidget && !showRebaseWithoutCi" :loading="isMakingRequest" variant="confirm" data-qa-selector="mr_rebase_button" + data-testid="standard-rebase-button" @click="rebase" > {{ __('Rebase') }} </gl-button> + <actions-button + v-if="!glFeatures.restructuredMrWidget && showRebaseWithoutCi" + :actions="actions" + :selected-key="selectedRebaseAction" + variant="confirm" + category="primary" + @select="selectRebaseAction" + /> <span v-if="!rebasingError" + :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="gl-font-weight-bold" data-testid="rebase-message" data-qa-selector="no_fast_forward_message_content" @@ -186,6 +231,17 @@ export default { <span v-else class="gl-font-weight-bold danger" data-testid="rebase-message">{{ rebasingError }}</span> + <gl-button + v-if="glFeatures.restructuredMrWidget" + :loading="isMakingRequest" + variant="confirm" + size="small" + data-qa-selector="mr_rebase_button" + class="gl-ml-3!" + @click="rebase" + > + {{ __('Rebase') }} + </gl-button> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue index 2d704d3b07a..e43319d42ca 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue @@ -62,8 +62,8 @@ export default { <gl-button v-if="mr.newBlobPath" :href="mr.newBlobPath" - category="secondary" - variant="success" + category="primary" + variant="confirm" data-testid="createFileButton" @click="onClickNewFile" > diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue index b5d2f91c637..d88dad2e086 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue @@ -1,6 +1,7 @@ <script> import { GlLink, GlSprintf } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { s__ } from '~/locale'; import statusIcon from '../mr_widget_status_icon.vue'; @@ -11,6 +12,7 @@ export default { GlSprintf, statusIcon, }, + mixins: [glFeatureFlagMixin()], computed: { troubleshootingDocsPath() { return helpPagePath('ci/troubleshooting', { anchor: 'merge-request-status-messages' }); @@ -28,7 +30,7 @@ export default { <div class="mr-widget-body media"> <status-icon :show-disabled-button="true" status="warning" /> <div class="media-body space-children"> - <span class="bold"> + <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold"> <gl-sprintf :message="$options.i18n.failedMessage"> <template #link="{ content }"> <gl-link :href="troubleshootingDocsPath" target="_blank"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index 8830128b7d6..06ce312bd4c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -529,7 +529,7 @@ export default { <template> <div :class="{ - 'gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-pl-7': + 'gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-pl-7 gl-rounded-bottom-left-base gl-rounded-bottom-right-base': glFeatures.restructuredMrWidget, }" > diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue index 7eeba8d8f89..b1fbe150fcf 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue @@ -1,5 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { I18N_SHA_MISMATCH } from '../../i18n'; import statusIcon from '../mr_widget_status_icon.vue'; @@ -12,6 +13,7 @@ export default { i18n: { I18N_SHA_MISMATCH, }, + mixins: [glFeatureFlagMixin()], props: { mr: { type: Object, @@ -25,7 +27,11 @@ export default { <div class="mr-widget-body media"> <status-icon :show-disabled-button="false" status="warning" /> <div class="media-body"> - <span class="gl-font-weight-bold" data-qa-selector="head_mismatch_content"> + <span + :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" + class="gl-font-weight-bold" + data-qa-selector="head_mismatch_content" + > {{ $options.i18n.I18N_SHA_MISMATCH.warningMessage }} </span> <gl-button diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue index 69e4df0ca11..8cf6383c26a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue @@ -1,5 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import notesEventHub from '~/notes/event_hub'; import statusIcon from '../mr_widget_status_icon.vue'; @@ -9,6 +10,7 @@ export default { statusIcon, GlButton, }, + mixins: [glFeatureFlagMixin()], props: { mr: { type: Object, @@ -25,16 +27,24 @@ export default { <template> <div class="mr-widget-body media gl-flex-wrap"> - <status-icon :show-disabled-button="true" status="warning" /> + <status-icon show-disabled-button status="warning" /> <div class="media-body"> - <span class="gl-ml-3 gl-font-weight-bold gl-display-block gl-w-100">{{ - s__('mrWidget|Merge blocked: all threads must be resolved.') - }}</span> + <span + :class="{ + 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget, + 'gl-display-block': !glFeatures.restructuredMrWidget, + }" + class="gl-ml-3 gl-font-weight-bold gl-w-100" + > + {{ s__('mrWidget|Merge blocked: all threads must be resolved.') }} + </span> <gl-button data-testid="jump-to-first" class="gl-ml-3" size="small" - icon="comment-next" + :icon="glFeatures.restructuredMrWidget ? undefined : 'comment-next'" + :variant="glFeatures.restructuredMrWidget && 'confirm'" + :category="glFeatures.restructuredMrWidget && 'secondary'" @click="jumpToFirstUnresolvedDiscussion" > {{ s__('mrWidget|Jump to first unresolved thread') }} @@ -44,7 +54,7 @@ export default { :href="mr.createIssueToResolveDiscussionsPath" class="js-create-issue gl-ml-3" size="small" - icon="issue-new" + :icon="glFeatures.restructuredMrWidget ? undefined : 'issue-new'" > {{ s__('mrWidget|Create issue to resolve all threads') }} </gl-button> 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 ba831a33b73..e0e19094c40 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 @@ -166,7 +166,10 @@ export default { <status-icon :show-disabled-button="canUpdate" status="warning" /> <div class="media-body"> <div class="float-left"> - <span class="gl-font-weight-bold"> + <span + :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" + class="gl-font-weight-bold" + > {{ __("Merge blocked: merge request must be marked as ready. It's still marked as draft.") }} diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index 2edccce7f4e..32effb91043 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -162,3 +162,6 @@ export const EXTENSION_SUMMARY_FAILED_CLASS = 'gl-text-red-500'; export const EXTENSION_SUMMARY_NEUTRAL_CLASS = 'gl-text-gray-700'; export { STATE_MACHINE }; + +export const REBASE_BUTTON_KEY = 'rebase'; +export const REBASE_WITHOUT_CI_BUTTON_KEY = 'rebaseWithoutCi'; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js new file mode 100644 index 00000000000..a564acada02 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js @@ -0,0 +1,173 @@ +import { __, n__, s__, sprintf } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import { EXTENSION_ICONS } from '../../constants'; + +export default { + name: 'WidgetTerraform', + enablePolling: true, + i18n: { + label: s__('Terraform|Terraform reports'), + loading: s__('Terraform|Loading Terraform reports...'), + error: s__('Terraform|Failed to load Terraform reports'), + reportGenerated: s__('Terraform|A Terraform report was generated in your pipelines.'), + namedReportGenerated: s__( + 'Terraform|The job %{strong_start}%{name}%{strong_end} generated a report.', + ), + reportChanges: s__( + 'Terraform|Reported Resource Changes: %{addNum} to add, %{changeNum} to change, %{deleteNum} to delete', + ), + reportFailed: s__('Terraform|A Terraform report failed to generate.'), + namedReportFailed: s__( + 'Terraform|The job %{strong_start}%{name}%{strong_end} failed to generate a report.', + ), + reportErrored: s__('Terraform|Generating the report caused an error.'), + fullLog: __('Full log'), + }, + expandEvent: 'i_testing_terraform_widget_total', + props: ['terraformReportsPath'], + computed: { + // Extension computed props + statusIcon() { + return EXTENSION_ICONS.warning; + }, + }, + methods: { + // Extension methods + summary({ valid = [], invalid = [] }) { + let title; + let subtitle = ''; + + const validText = sprintf( + n__( + 'Terraform|%{strong_start}%{number}%{strong_end} Terraform report was generated in your pipelines', + 'Terraform|%{strong_start}%{number}%{strong_end} Terraform reports were generated in your pipelines', + valid.length, + ), + { + number: valid.length, + }, + false, + ); + + const invalidText = sprintf( + n__( + 'Terraform|%{strong_start}%{number}%{strong_end} Terraform report failed to generate', + 'Terraform|%{strong_start}%{number}%{strong_end} Terraform reports failed to generate', + invalid.length, + ), + { + number: invalid.length, + }, + false, + ); + + if (valid.length) { + title = validText; + if (invalid.length) { + subtitle = sprintf(`<br>%{small_start}${invalidText}%{small_end}`); + } + } else { + title = invalidText; + } + + return `${title}${subtitle}`; + }, + fetchCollapsedData() { + return Promise.resolve(this.fetchPlans().then(this.prepareReports)); + }, + fetchFullData() { + const { valid, invalid } = this.collapsedData; + return Promise.resolve([...valid, ...invalid]); + }, + // Custom methods + fetchPlans() { + return axios + .get(this.terraformReportsPath) + .then(({ data }) => { + return Object.keys(data).map((key) => { + return data[key]; + }); + }) + .catch(() => { + const invalidData = { tf_report_error: 'api_error' }; + return [invalidData]; + }); + }, + createReportRow(report, iconName) { + const addNum = Number(report.create); + const changeNum = Number(report.update); + const deleteNum = Number(report.delete); + const validPlanValues = addNum + changeNum + deleteNum >= 0; + + const actions = []; + + let title; + let subtitle; + + if (report.job_path) { + const action = { + href: report.job_path, + text: this.$options.i18n.fullLog, + target: '_blank', + }; + actions.push(action); + } + + if (validPlanValues) { + if (report.job_name) { + title = sprintf( + this.$options.i18n.namedReportGenerated, + { + name: report.job_name, + }, + false, + ); + } else { + title = this.$options.i18n.reportGenerated; + } + + subtitle = sprintf(`%{small_start}${this.$options.i18n.reportChanges}%{small_end}`, { + addNum, + changeNum, + deleteNum, + }); + } else { + if (report.job_name) { + title = sprintf( + this.$options.i18n.namedReportFailed, + { + name: report.job_name, + }, + false, + ); + } else { + title = this.$options.i18n.reportFailed; + } + + subtitle = sprintf(`%{small_start}${this.$options.i18n.reportErrored}%{small_end}`); + } + + return { + text: `${title} + <br> + ${subtitle}`, + icon: { name: iconName }, + actions, + }; + }, + prepareReports(reports) { + const valid = []; + const invalid = []; + + reports.forEach((report) => { + if (report.tf_report_error) { + invalid.push(this.createReportRow(report, EXTENSION_ICONS.error)); + } else { + valid.push(this.createReportRow(report, EXTENSION_ICONS.success)); + } + }); + + return { valid, invalid }; + }, + }, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index c98dc426224..83a07240403 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -1,6 +1,7 @@ <script> import { GlSafeHtmlDirective } from '@gitlab/ui'; import { isEmpty } from 'lodash'; +import { registerExtension } from '~/vue_merge_request_widget/components/extensions'; import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue'; import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service'; import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store'; @@ -43,6 +44,7 @@ import { STATE_MACHINE, stateToComponentMap } from './constants'; import eventHub from './event_hub'; import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables'; import getStateQuery from './queries/get_state.query.graphql'; +import terraformExtension from './extensions/terraform'; export default { // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25 @@ -184,6 +186,9 @@ export default { shouldRenderSecurityReport() { return Boolean(this.mr.pipeline.id); }, + shouldRenderTerraformPlans() { + return Boolean(this.mr?.terraformReportsPath); + }, mergeError() { let { mergeError } = this.mr; @@ -230,6 +235,11 @@ export default { this.initPostMergeDeploymentsPolling(); } }, + shouldRenderTerraformPlans(newVal) { + if (newVal) { + this.registerTerraformPlans(); + } + }, }, mounted() { MRWidgetService.fetchInitialData() @@ -463,6 +473,11 @@ export default { dismissSuggestPipelines() { this.mr.isDismissedSuggestPipeline = true; }, + registerTerraformPlans() { + if (this.shouldRenderTerraformPlans && this.shouldShowExtension) { + registerExtension(terraformExtension); + } + }, }, }; </script> @@ -542,7 +557,10 @@ export default { :pipeline-path="mr.pipeline.path" /> - <terraform-plan v-if="mr.terraformReportsPath" :endpoint="mr.terraformReportsPath" /> + <terraform-plan + v-if="mr.terraformReportsPath && !shouldShowExtension" + :endpoint="mr.terraformReportsPath" + /> <grouped-accessibility-reports-app v-if="shouldShowAccessibilityReport" diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js index 7dcb4881e7f..7b803b0fcbb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js +++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js @@ -55,8 +55,9 @@ export default class MRWidgetService { return axios.get(this.endpoints.mergeActionsContentPath); } - rebase() { - return axios.post(this.endpoints.rebasePath); + rebase({ skipCi = false } = {}) { + const path = `${this.endpoints.rebasePath}?skip_ci=${Boolean(skipCi)}`; + return axios.post(path); } fetchApprovals() { diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 57af869a0ba..5378dabf638 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -222,6 +222,8 @@ export default class MergeRequestStore { this.showGitpodButton = data.show_gitpod_button; this.gitpodUrl = data.gitpod_url; this.gitpodEnabled = data.gitpod_enabled; + this.userPreferencesGitpodPath = data.user_preferences_gitpod_path; + this.userProfileEnableGitpodPath = data.user_profile_enable_gitpod_path; } setState() { @@ -357,15 +359,13 @@ export default class MergeRequestStore { setApprovals(data) { this.approvals = data; this.isApproved = data.approved || false; + + this.setState(); } + // eslint-disable-next-line class-methods-use-this get hasMergeChecksFailed() { - if (!window.gon?.features?.restructuredMrWidget) return false; - - return ( - this.hasMergeableDiscussionsState || - (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) - ); + return false; } // Because the state machine doesn't yet handle every state and transition, diff --git a/app/assets/javascripts/vue_shared/components/actions_button.vue b/app/assets/javascripts/vue_shared/components/actions_button.vue index bab13fe7c75..6db18afe51c 100644 --- a/app/assets/javascripts/vue_shared/components/actions_button.vue +++ b/app/assets/javascripts/vue_shared/components/actions_button.vue @@ -66,6 +66,7 @@ export default { :variant="variant" :category="category" split + data-qa-selector="action_dropdown" @click="handleClick(selectedAction, $event)" > <template #button-content> @@ -79,6 +80,7 @@ export default { :is-check-item="true" :is-checked="action.key === selectedAction.key" :secondary-text="action.secondaryText" + :data-qa-selector="`${action.key}_menu_item`" :data-testid="`action_${action.key}`" @click="handleItemClick(action)" > diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue index 400be3ef688..f907b64608c 100644 --- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue +++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue @@ -13,9 +13,23 @@ * /> */ import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; + +import { __ } from '~/locale'; +import { + CLIPBOARD_SUCCESS_EVENT, + CLIPBOARD_ERROR_EVENT, + I18N_ERROR_MESSAGE, +} from '~/behaviors/copy_to_clipboard'; export default { name: 'ClipboardButton', + i18n: { + copied: __('Copied'), + error: I18N_ERROR_MESSAGE, + }, + CLIPBOARD_SUCCESS_EVENT, + CLIPBOARD_ERROR_EVENT, directives: { GlTooltip: GlTooltipDirective, }, @@ -72,6 +86,13 @@ export default { default: 'default', }, }, + data() { + return { + localTitle: this.title, + titleTimeout: null, + id: null, + }; + }, computed: { clipboardText() { if (this.gfm !== null) { @@ -79,25 +100,50 @@ export default { } return this.text; }, + tooltipDirectiveOptions() { + return { + placement: this.tooltipPlacement, + container: this.tooltipContainer, + boundary: this.tooltipBoundary, + }; + }, + }, + created() { + this.id = uniqueId('clipboard-button-'); + }, + methods: { + updateTooltip(title) { + this.localTitle = title; + this.$root.$emit('bv::show::tooltip', this.id); + + clearTimeout(this.titleTimeout); + + this.titleTimeout = setTimeout(() => { + this.localTitle = this.title; + this.$root.$emit('bv::hide::tooltip', this.id); + }, 1000); + }, }, }; </script> <template> <gl-button - v-gl-tooltip.hover.blur.viewport="{ - placement: tooltipPlacement, - container: tooltipContainer, - boundary: tooltipBoundary, - }" + :id="id" + ref="copyButton" + v-gl-tooltip.hover.focus.click.viewport="tooltipDirectiveOptions" :class="cssClass" - :title="title" + :title="localTitle" :data-clipboard-text="clipboardText" + data-clipboard-handle-tooltip="false" :category="category" :size="size" icon="copy-to-clipboard" - :aria-label="__('Copy this value')" :variant="variant" + :aria-label="localTitle" + aria-live="polite" + @[$options.CLIPBOARD_SUCCESS_EVENT]="updateTooltip($options.i18n.copied)" + @[$options.CLIPBOARD_ERROR_EVENT]="updateTooltip($options.i18n.error)" v-on="$listeners" > <slot></slot> diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue index f93415ced45..e12e06a2454 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue @@ -36,6 +36,11 @@ export default { required: false, default: 'confirm-danger-button', }, + buttonVariant: { + type: String, + required: false, + default: 'danger', + }, }, modalId: CONFIRM_DANGER_MODAL_ID, }; @@ -45,7 +50,7 @@ export default { <gl-button v-gl-modal="$options.modalId" :class="buttonClass" - variant="danger" + :variant="buttonVariant" :disabled="disabled" :data-testid="buttonTestid" >{{ buttonText }}</gl-button diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js index 18fa297da87..6629b293eb9 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js @@ -11,7 +11,9 @@ const Template = (args, { argTypes }) => ({ props: Object.keys(argTypes), template: '<confirm-danger v-bind="$props" />', provide: { - confirmDangerMessage: 'You require more Vespene Gas', + additionalInformation: args.additionalInformation || null, + confirmDangerMessage: args.confirmDangerMessage || 'You require more Vespene Gas', + htmlConfirmationMessage: args.confirmDangerMessage || false, }, }); @@ -26,3 +28,16 @@ Disabled.args = { ...Default.args, disabled: true, }; + +export const AdditionalInformation = Template.bind({}); +AdditionalInformation.args = { + ...Default.args, + additionalInformation: 'This replaces the default warning information', +}; + +export const HtmlMessage = Template.bind({}); +HtmlMessage.args = { + ...Default.args, + confirmDangerMessage: 'You strongly require more <strong>Vespene Gas</strong>', + htmlConfirmationMessage: true, +}; diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue index 5bbe44b20b3..88890b3332d 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue @@ -1,5 +1,12 @@ <script> -import { GlAlert, GlModal, GlFormGroup, GlFormInput, GlSprintf } from '@gitlab/ui'; +import { + GlAlert, + GlModal, + GlFormGroup, + GlFormInput, + GlSafeHtmlDirective as SafeHtml, + GlSprintf, +} from '@gitlab/ui'; import { CONFIRM_DANGER_MODAL_BUTTON, CONFIRM_DANGER_MODAL_TITLE, @@ -17,13 +24,22 @@ export default { GlFormInput, GlSprintf, }, + directives: { + SafeHtml, + }, inject: { + htmlConfirmationMessage: { + default: false, + }, confirmDangerMessage: { default: '', }, confirmButtonText: { default: CONFIRM_DANGER_MODAL_BUTTON, }, + additionalInformation: { + default: CONFIRM_DANGER_WARNING, + }, }, props: { modalId: { @@ -81,9 +97,12 @@ export default { :dismissible="false" class="gl-mb-4" > - {{ confirmDangerMessage }} + <span v-if="htmlConfirmationMessage" v-safe-html="confirmDangerMessage"></span> + <span v-else> + {{ confirmDangerMessage }} + </span> </gl-alert> - <p data-testid="confirm-danger-warning">{{ $options.i18n.CONFIRM_DANGER_WARNING }}</p> + <p data-testid="confirm-danger-warning">{{ additionalInformation }}</p> <p data-testid="confirm-danger-phrase"> <gl-sprintf :message="$options.i18n.CONFIRM_DANGER_PHRASE_TEXT"> <template #phrase_code> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index 7c1828f2294..5cdf7b6a3b2 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -332,7 +332,7 @@ export default { v-if="showCheckbox" class="gl-align-self-center" :checked="checkboxChecked" - @input="$emit('checked-input', $event)" + @change="$emit('checked-input', $event)" > <span class="gl-sr-only">{{ __('Select all') }}</span> </gl-form-checkbox> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue index 06478a89721..b70317b2ec4 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue @@ -4,7 +4,7 @@ import { compact } from 'lodash'; import createFlash from '~/flash'; import { __ } from '~/locale'; -import { DEFAULT_LABEL_ANY } from '../constants'; +import { DEFAULT_NONE_ANY } from '../constants'; import BaseToken from './base_token.vue'; @@ -36,7 +36,7 @@ export default { }, computed: { defaultAuthors() { - return this.config.defaultAuthors || [DEFAULT_LABEL_ANY]; + return this.config.defaultAuthors || DEFAULT_NONE_ANY; }, preloadedAuthors() { return this.config.preloadedAuthors || []; diff --git a/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue b/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue new file mode 100644 index 00000000000..acddf16bd27 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue @@ -0,0 +1,67 @@ +<script> +import { GlBadge } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; + +const STATUS_TYPES = { + SUCCESS: 'success', + WARNING: 'warning', + DANGER: 'danger', +}; + +export default { + name: 'GitlabVersionCheck', + components: { + GlBadge, + }, + props: { + size: { + type: String, + required: false, + default: 'md', + }, + }, + data() { + return { + status: null, + }; + }, + computed: { + title() { + if (this.status === STATUS_TYPES.SUCCESS) { + return s__('VersionCheck|Up to date'); + } else if (this.status === STATUS_TYPES.WARNING) { + return s__('VersionCheck|Update available'); + } else if (this.status === STATUS_TYPES.DANGER) { + return s__('VersionCheck|Update ASAP'); + } + + return null; + }, + }, + created() { + this.checkGitlabVersion(); + }, + methods: { + checkGitlabVersion() { + axios + .get('/admin/version_check.json') + .then((res) => { + if (res.data) { + this.status = res.data.severity; + } + }) + .catch(() => { + // Silently fail + this.status = null; + }); + }, + }, +}; +</script> + +<template> + <gl-badge v-if="status" class="version-check-badge" :variant="status" :size="size">{{ + title + }}</gl-badge> +</template> diff --git a/app/assets/javascripts/vue_shared/components/line_numbers.vue b/app/assets/javascripts/vue_shared/components/line_numbers.vue index 7e17cca3dcc..11caf3be00a 100644 --- a/app/assets/javascripts/vue_shared/components/line_numbers.vue +++ b/app/assets/javascripts/vue_shared/components/line_numbers.vue @@ -12,31 +12,6 @@ export default { required: true, }, }, - data() { - return { - currentlyHighlightedLine: null, - }; - }, - mounted() { - this.scrollToLine(); - }, - methods: { - scrollToLine(hash = window.location.hash) { - const lineToHighlight = hash && this.$el.querySelector(hash); - - if (!lineToHighlight) { - return; - } - - if (this.currentlyHighlightedLine) { - this.currentlyHighlightedLine.classList.remove('hll'); - } - - lineToHighlight.classList.add('hll'); - this.currentlyHighlightedLine = lineToHighlight; - lineToHighlight.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }, - }, }; </script> <template> @@ -45,10 +20,9 @@ export default { v-for="line in lines" :id="`L${line}`" :key="line" - class="diff-line-num" - :href="`#L${line}`" + class="diff-line-num gl-shadow-none!" + :to="`#LC${line}`" :data-line-number="line" - @click="scrollToLine(`#L${line}`)" > <gl-icon :size="12" name="link" /> {{ line }} diff --git a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue index ce7cbafb97d..709d3592828 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue @@ -67,7 +67,7 @@ export default { <gl-button class="gl-w-auto! gl-mt-3 gl-text-center! gl-hover-text-white! gl-transition-medium! float-right" category="primary" - variant="success" + variant="confirm" data-qa-selector="commit_with_custom_message_button" @click="onApply" > diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 86f04c78ebe..5c86c928ce3 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -2,7 +2,7 @@ import { GlIcon } from '@gitlab/ui'; import $ from 'jquery'; import '~/behaviors/markdown/render_gfm'; -import { unescape } from 'lodash'; +import { debounce, unescape } from 'lodash'; import createFlash from '~/flash'; import GLForm from '~/gl_form'; import axios from '~/lib/utils/axios_utils'; @@ -110,7 +110,7 @@ export default { return { markdownPreview: '', referencedCommands: '', - referencedUsers: '', + referencedUsers: [], hasSuggestion: false, markdownPreviewLoading: false, previewMarkdown: false, @@ -188,6 +188,24 @@ export default { }); } }, + + textareaValue: { + immediate: true, + handler(textareaValue, oldVal) { + const all = /@all([^\w._-]|$)/; + const hasAll = all.test(textareaValue); + const hadAll = all.test(oldVal); + + const justAddedAll = !hadAll && hasAll; + const justRemovedAll = hadAll && !hasAll; + + if (justAddedAll) { + this.debouncedFetchMarkdown(); + } else if (justRemovedAll) { + this.referencedUsers = []; + } + }, + }, }, mounted() { // GLForm class handles all the toolbar buttons @@ -222,9 +240,9 @@ export default { if (this.textareaValue) { this.markdownPreviewLoading = true; this.markdownPreview = __('Loading…'); - axios - .post(this.markdownPreviewPath, { text: this.textareaValue }) - .then((response) => this.renderMarkdown(response.data)) + + this.fetchMarkdown() + .then((data) => this.renderMarkdown(data)) .catch(() => createFlash({ message: __('Error loading markdown preview'), @@ -239,17 +257,28 @@ export default { this.previewMarkdown = false; }, + fetchMarkdown() { + return axios.post(this.markdownPreviewPath, { text: this.textareaValue }).then(({ data }) => { + const { references } = data; + if (references) { + this.referencedCommands = references.commands; + this.referencedUsers = references.users; + this.hasSuggestion = references.suggestions?.length > 0; + this.suggestions = references.suggestions; + } + + return data; + }); + }, + + debouncedFetchMarkdown: debounce(function debouncedFetchMarkdown() { + return this.fetchMarkdown(); + }, 400), + renderMarkdown(data = {}) { this.markdownPreviewLoading = false; this.markdownPreview = data.body || __('Nothing to preview.'); - if (data.references) { - this.referencedCommands = data.references.commands; - this.referencedUsers = data.references.users; - this.hasSuggestion = data.references.suggestions && data.references.suggestions.length; - this.suggestions = data.references.suggestions; - } - this.$nextTick() .then(() => $(this.$refs['markdown-preview']).renderGFM()) .catch(() => @@ -326,18 +355,14 @@ export default { v-html="markdownPreview /* eslint-disable-line vue/no-v-html */" ></div> </template> - <template v-if="previewMarkdown && !markdownPreviewLoading"> - <div - v-if="referencedCommands" - class="referenced-commands" - v-html="referencedCommands /* eslint-disable-line vue/no-v-html */" - ></div> - <div v-if="shouldShowReferencedUsers" class="referenced-users"> - <gl-icon name="warning-solid" /> - <span - v-html="addMultipleToDiscussionWarning /* eslint-disable-line vue/no-v-html */" - ></span> - </div> - </template> + <div + v-if="referencedCommands && previewMarkdown && !markdownPreviewLoading" + class="referenced-commands" + v-html="referencedCommands /* eslint-disable-line vue/no-v-html */" + ></div> + <div v-if="shouldShowReferencedUsers" class="referenced-users"> + <gl-icon name="warning-solid" /> + <span v-html="addMultipleToDiscussionWarning /* eslint-disable-line vue/no-v-html */"></span> + </div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue index 38afd56bae6..d4f50e347cb 100644 --- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue +++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue @@ -1,6 +1,6 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import Clipboard from 'clipboard'; +import ClipboardJS from 'clipboard'; import { uniqueId } from 'lodash'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; @@ -69,7 +69,7 @@ export default { }, mounted() { this.$nextTick(() => { - this.clipboard = new Clipboard(this.$el, { + this.clipboard = new ClipboardJS(this.$el, { container: document.querySelector(`${this.modalDomId} div.modal-content`) || document.getElementById(this.container) || diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue index 13a6dd43207..0fa64a29b3a 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue @@ -179,6 +179,9 @@ export default { this.searchKey = ''; this.setFocus(); }, + selectFirstItem() { + this.$refs.dropdownContentsView.selectFirstItem(); + }, }, }; </script> @@ -204,11 +207,13 @@ export default { @toggleDropdownContentsCreateView="toggleDropdownContent" @closeDropdown="$emit('closeDropdown')" @input="debouncedSearchKeyUpdate" + @searchEnter="selectFirstItem" /> </template> <template #default> <component :is="dropdownContentsView" + ref="dropdownContentsView" v-model="localSelectedLabels" :search-key="searchKey" :allow-multiselect="allowMultiselect" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue index da626a21b14..b99083713a8 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue @@ -1,5 +1,12 @@ <script> -import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { + GlAlert, + GlTooltipDirective, + GlButton, + GlFormInput, + GlLink, + GlLoadingIcon, +} from '@gitlab/ui'; import produce from 'immer'; import createFlash from '~/flash'; import { __ } from '~/locale'; @@ -11,6 +18,7 @@ const errorMessage = __('Error creating label.'); export default { components: { + GlAlert, GlButton, GlFormInput, GlLink, @@ -42,6 +50,7 @@ export default { labelTitle: '', selectedColor: '', labelCreateInProgress: false, + error: undefined, }; }, computed: { @@ -111,13 +120,14 @@ export default { ) => this.updateLabelsInCache(store, label), }); if (labelCreate.errors.length) { - createFlash({ message: errorMessage }); + [this.error] = labelCreate.errors; + } else { + this.$emit('hideCreateView'); } } catch { createFlash({ message: errorMessage }); } this.labelCreateInProgress = false; - this.$emit('hideCreateView'); }, }, }; @@ -126,6 +136,9 @@ export default { <template> <div class="labels-select-contents-create js-labels-create"> <div class="dropdown-input"> + <gl-alert v-if="error" variant="danger" :dismissible="false" class="gl-mb-3"> + {{ error }} + </gl-alert> <gl-form-input v-model.trim="labelTitle" :placeholder="__('Name new label')" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue index e9a2d7747e2..ae179ef93c7 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue @@ -84,6 +84,9 @@ export default { showNoMatchingResultsMessage() { return Boolean(this.searchKey) && this.visibleLabels.length === 0; }, + shouldHighlightFirstItem() { + return this.searchKey !== '' && this.visibleLabels.length > 0; + }, }, methods: { isLabelSelected(label) { @@ -128,6 +131,11 @@ export default { onDropdownAppear() { this.isVisible = true; }, + selectFirstItem() { + if (this.shouldHighlightFirstItem) { + this.handleLabelClick(this.visibleLabels[0]); + } + }, }, }; </script> @@ -143,11 +151,13 @@ export default { /> <template v-else> <gl-dropdown-item - v-for="label in visibleLabels" + v-for="(label, index) in visibleLabels" :key="label.id" :is-checked="isLabelSelected(label)" :is-check-centered="true" :is-check-item="true" + :active="shouldHighlightFirstItem && index === 0" + active-class="is-focused" data-testid="labels-list" @click.native.capture.stop="handleLabelClick(label)" > diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue index 7a0f20b0c83..faad69732dd 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue @@ -83,6 +83,7 @@ export default { data-qa-selector="dropdown_input_field" data-testid="dropdown-input-field" @input="$emit('input', $event)" + @keydown.enter="$emit('searchEnter', $event)" /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue index f27f0b4e34c..caeee2df7e5 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue @@ -10,7 +10,7 @@ export default { </script> <template> - <div> + <div class="gl-display-flex gl-align-items-center"> <span class="dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3" :style="{ 'background-color': label.color }" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue index 3adda69b892..f53b75df4eb 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue @@ -289,6 +289,7 @@ export default { 'is-standalone': isDropdownVariantStandalone(variant), 'is-embedded': isDropdownVariantEmbedded(variant), }" + data-testid="sidebar-labels" data-qa-selector="labels_block" > <template v-if="isDropdownVariantSidebar(variant)"> diff --git a/app/assets/javascripts/vue_shared/components/source_editor.vue b/app/assets/javascripts/vue_shared/components/source_editor.vue index 8a0fef36079..011cad4267c 100644 --- a/app/assets/javascripts/vue_shared/components/source_editor.vue +++ b/app/assets/javascripts/vue_shared/components/source_editor.vue @@ -97,7 +97,7 @@ export default { ref="editor" data-editor-loading data-qa-selector="source_editor_container" - @[$options.readyEvent]="$emit($options.readyEvent)" + @[$options.readyEvent]="$emit($options.readyEvent, $event)" > <pre class="editor-loading-content">{{ value }}</pre> </div> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer.vue index 8f0d051543f..99895926653 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer.vue @@ -1,6 +1,9 @@ <script> import { GlSafeHtmlDirective } from '@gitlab/ui'; import LineNumbers from '~/vue_shared/components/line_numbers.vue'; +import { sanitize } from '~/lib/dompurify'; + +const LINE_SELECT_CLASS_NAME = 'hll'; export default { components: { @@ -46,7 +49,15 @@ export default { } } - return highlightedContent; + return this.wrapLines(highlightedContent); + }, + }, + watch: { + highlightedContent() { + this.$nextTick(() => this.selectLine()); + }, + $route() { + this.selectLine(); }, }, async mounted() { @@ -73,16 +84,40 @@ export default { return languageDefinition; }, + wrapLines(content) { + return ( + content && + content + .split('\n') + .map((line, i) => `<span id="LC${i + 1}" class="line">${line}</span>`) + .join('\r\n') + ); + }, + selectLine() { + const hash = sanitize(this.$route.hash); + const lineToSelect = hash && this.$el.querySelector(hash); + + if (!lineToSelect) { + return; + } + + if (this.$options.currentlySelectedLine) { + this.$options.currentlySelectedLine.classList.remove(LINE_SELECT_CLASS_NAME); + } + + lineToSelect.classList.add(LINE_SELECT_CLASS_NAME); + this.$options.currentlySelectedLine = lineToSelect; + lineToSelect.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, }, userColorScheme: window.gon.user_color_scheme, + currentlySelectedLine: null, }; </script> <template> - <div class="file-content code" :class="$options.userColorScheme"> + <div class="file-content code js-syntax-highlight" :class="$options.userColorScheme"> <line-numbers :lines="lineNumbers" /> - <pre - class="code gl-pl-3!" - ><code v-safe-html="highlightedContent" class="gl-white-space-pre-wrap!"></code> + <pre class="code"><code v-safe-html="highlightedContent"></code> </pre> </div> </template> 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 6da2d39a95a..f02cd5c4e2e 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -1,6 +1,7 @@ <script> import $ from 'jquery'; -import { __ } from '~/locale'; +import { GlModal, GlSprintf, GlLink } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; import ActionsButton from '~/vue_shared/components/actions_button.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; @@ -12,6 +13,19 @@ export default { components: { ActionsButton, LocalStorageSync, + GlModal, + GlSprintf, + GlLink, + }, + i18n: { + modal: { + title: __('Enable Gitpod?'), + content: s__( + 'Gitpod|To use Gitpod you must first enable the feature in the integrations section of your %{linkStart}user preferences%{linkEnd}.', + ), + actionCancelText: __('Cancel'), + actionPrimaryText: __('Enable Gitpod'), + }, }, props: { isFork: { @@ -49,6 +63,16 @@ export default { required: false, default: false, }, + userPreferencesGitpodPath: { + type: String, + required: false, + default: '', + }, + userProfileEnableGitpodPath: { + type: String, + required: false, + default: '', + }, editUrl: { type: String, required: false, @@ -74,10 +98,16 @@ export default { required: false, default: '', }, + disableForkModal: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { selection: KEY_WEB_IDE, + showEnableGitpodModal: false, }; }, computed: { @@ -93,8 +123,12 @@ export default { ? { href: '#modal-confirm-fork-edit', handle: () => { - this.$emit('edit', 'simple'); - this.showModal('#modal-confirm-fork-edit'); + if (this.disableForkModal) { + this.$emit('edit', 'simple'); + return; + } + + this.showJQueryModal('#modal-confirm-fork-edit'); }, } : { href: this.editUrl }; @@ -132,8 +166,12 @@ export default { ? { href: '#modal-confirm-fork-webide', handle: () => { - this.$emit('edit', 'ide'); - this.showModal('#modal-confirm-fork-webide'); + if (this.disableForkModal) { + this.$emit('edit', 'ide'); + return; + } + + this.showJQueryModal('#modal-confirm-fork-webide'); }, } : { href: this.webIdeUrl }; @@ -154,14 +192,23 @@ export default { gitpodActionText() { return this.gitpodText || __('Gitpod'); }, + computedShowGitpodButton() { + return ( + this.showGitpodButton && this.userPreferencesGitpodPath && this.userProfileEnableGitpodPath + ); + }, gitpodAction() { - if (!this.showGitpodButton) { + if (!this.computedShowGitpodButton) { return null; } const handleOptions = this.gitpodEnabled ? { href: this.gitpodUrl } - : { href: '#modal-enable-gitpod', handle: () => this.showModal('#modal-enable-gitpod') }; + : { + handle: () => { + this.showModal('showEnableGitpodModal'); + }, + }; const secondaryText = __('Launch a ready-to-code development environment for your project.'); @@ -176,14 +223,36 @@ export default { ...handleOptions, }; }, + enableGitpodModalProps() { + return { + 'modal-id': 'enable-gitpod-modal', + size: 'sm', + title: this.$options.i18n.modal.title, + 'action-cancel': { + text: this.$options.i18n.modal.actionCancelText, + }, + 'action-primary': { + text: this.$options.i18n.modal.actionPrimaryText, + attributes: { + variant: 'confirm', + category: 'primary', + href: this.userProfileEnableGitpodPath, + 'data-method': 'put', + }, + }, + }; + }, }, methods: { select(key) { this.selection = key; }, - showModal(id) { + showJQueryModal(id) { $(id).modal('show'); }, + showModal(dataKey) { + this[dataKey] = true; + }, }, }; </script> @@ -202,5 +271,16 @@ export default { :value="selection" @input="select" /> + <gl-modal + v-if="computedShowGitpodButton && !gitpodEnabled" + v-model="showEnableGitpodModal" + v-bind="enableGitpodModalProps" + > + <gl-sprintf :message="$options.i18n.modal.content"> + <template #link="{ content }"> + <gl-link :href="userPreferencesGitpodPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue index 5ca9e50d854..bcc889495bd 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue @@ -13,6 +13,7 @@ export default { if (layoutPageEl) { layoutPageEl.classList.toggle('right-sidebar-expanded', value); layoutPageEl.classList.toggle('right-sidebar-collapsed', !value); + layoutPageEl.classList.toggle('issuable-bulk-update-sidebar', !value); } }, }, diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue index 0bb0e0d9fb0..af0235bfc69 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue @@ -193,7 +193,13 @@ export default { :title="__('This issue is hidden because its author has been banned')" :aria-label="__('Hidden')" /> - <gl-link class="issue-title-text" dir="auto" :href="webUrl" v-bind="issuableTitleProps"> + <gl-link + class="issue-title-text" + dir="auto" + :href="webUrl" + data-qa-selector="issuable_title_link" + v-bind="issuableTitleProps" + > {{ issuable.title }} <gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" /> </gl-link> diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue index 3ff87ba3c4f..9bf54e98cc4 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue @@ -1,5 +1,6 @@ <script> import { GlTabs, GlTab, GlBadge } from '@gitlab/ui'; +import { formatNumber } from '~/locale'; export default { components: { @@ -29,6 +30,9 @@ export default { isTabCountNumeric(tab) { return Number.isInteger(this.tabCounts[tab.name]); }, + formatNumber(count) { + return formatNumber(count); + }, }, }; </script> @@ -55,7 +59,7 @@ export default { size="sm" class="gl-tab-counter-badge" > - {{ tabCounts[tab.name] }} + {{ formatNumber(tabCounts[tab.name]) }} </gl-badge> </template> </gl-tab> diff --git a/app/assets/javascripts/vue_shared/issuable/list/constants.js b/app/assets/javascripts/vue_shared/issuable/list/constants.js index 773ad0f8e93..c6dce6a51c2 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/constants.js +++ b/app/assets/javascripts/vue_shared/issuable/list/constants.js @@ -38,7 +38,7 @@ export const AvailableSortOptions = [ }, { id: 2, - title: __('Last updated'), + title: __('Updated date'), sortDirection: { descending: 'updated_desc', ascending: 'updated_asc', diff --git a/app/assets/javascripts/vue_shared/issuable/show/constants.js b/app/assets/javascripts/vue_shared/issuable/show/constants.js deleted file mode 100644 index 346f45c7d90..00000000000 --- a/app/assets/javascripts/vue_shared/issuable/show/constants.js +++ /dev/null @@ -1,5 +0,0 @@ -export const IssuableType = { - Issue: 'issue', - Incident: 'incident', - TestCase: 'test_case', -}; diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue index 114f60c96ee..f67e590e2ce 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue @@ -10,10 +10,17 @@ export default { GlIcon, WelcomePage, LegacyContainer, + CreditCardVerification: () => + import('ee_component/pages/groups/new/components/credit_card_verification.vue'), }, directives: { SafeHtml, }, + inject: { + verificationRequired: { + default: false, + }, + }, props: { title: { type: String, @@ -41,6 +48,7 @@ export default { data() { return { activePanelName: null, + verificationCompleted: false, }; }, @@ -67,6 +75,10 @@ export default { { text: this.activePanel.title, href: `#${this.activePanel.name}` }, ]; }, + + shouldVerify() { + return this.verificationRequired && !this.verificationCompleted; + }, }, created() { @@ -93,12 +105,16 @@ export default { localStorage.setItem(this.persistenceKey, this.activePanelName); } }, + onVerified() { + this.verificationCompleted = true; + }, }, }; </script> <template> - <welcome-page v-if="!activePanelName" :panels="panels" :title="title"> + <credit-card-verification v-if="shouldVerify" @verified="onVerified" /> + <welcome-page v-else-if="!activePanelName" :panels="panels" :title="title"> <template #footer> <slot name="welcome-footer"> </slot> </template> diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue index 5e9e50a94f0..79840cc4f0f 100644 --- a/app/assets/javascripts/work_items/components/item_title.vue +++ b/app/assets/javascripts/work_items/components/item_title.vue @@ -2,7 +2,10 @@ import { escape } from 'lodash'; import { __ } from '~/locale'; +import { WI_TITLE_TRACK_LABEL } from '../constants'; + export default { + WI_TITLE_TRACK_LABEL, props: { initialTitle: { type: String, @@ -56,6 +59,7 @@ export default { role="textbox" :aria-label="__('Title')" :data-placeholder="placeholder" + :data-track-label="$options.WI_TITLE_TRACK_LABEL" :contenteditable="!disabled" class="gl-pseudo-placeholder" @blur="handleBlur" diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index b39f68abf74..995c02a2c5b 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -1,3 +1,5 @@ export const widgetTypes = { title: 'TITLE', }; + +export const WI_TITLE_TRACK_LABEL = 'item_title'; diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue index 479274baf3a..4262e169655 100644 --- a/app/assets/javascripts/work_items/pages/work_item_root.vue +++ b/app/assets/javascripts/work_items/pages/work_item_root.vue @@ -1,16 +1,21 @@ <script> import { GlAlert } from '@gitlab/ui'; +import Tracking from '~/tracking'; import workItemQuery from '../graphql/work_item.query.graphql'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; -import { widgetTypes } from '../constants'; +import { widgetTypes, WI_TITLE_TRACK_LABEL } from '../constants'; import ItemTitle from '../components/item_title.vue'; +const trackingMixin = Tracking.mixin(); + export default { + titleUpdatedEvent: 'updated_title', components: { ItemTitle, GlAlert, }, + mixins: [trackingMixin], props: { id: { type: String, @@ -34,6 +39,14 @@ export default { }, }, computed: { + tracking() { + return { + category: 'workItems:show', + action: 'updated_title', + label: WI_TITLE_TRACK_LABEL, + property: '[type_work_item]', + }; + }, titleWidgetData() { return this.workItem?.widgets?.nodes?.find((widget) => widget.type === widgetTypes.title); }, @@ -50,6 +63,7 @@ export default { }, }, }); + this.track(); } catch { this.error = true; } |